Compare commits

...

55 Commits

Author SHA1 Message Date
Leendert de Borst
2cfc8d528d Merge pull request #538 from lanedirt/537-prepare-0110-release
Bump version to 0.11.0
2025-01-14 13:51:15 +00:00
Leendert de Borst
7a4e1721c8 Bump version to 0.11.0 (#537) 2025-01-14 14:42:56 +01:00
Leendert de Borst
11d79c4874 Merge pull request #536 from lanedirt/535-make-application-warning-logs-more-readable
Make duplicate email warning log more readable
2025-01-14 13:32:16 +00:00
Leendert de Borst
7cd35b0a92 Make duplicate email warning log more readable (#535) 2025-01-14 12:30:36 +01:00
Leendert de Borst
d0f62a26c0 Merge pull request #534 from lanedirt/530-update-sonarcloud-github-action-to-work-with-pr-from-forks
Update sonarcloud-code-analysis so it works for main and PRs (#530)
2025-01-14 11:20:41 +00:00
Leendert de Borst
01198502a3 Update sonarcloud-code-analysis so it works for main and PRs (#530) 2025-01-14 12:20:14 +01:00
Leendert de Borst
229ad109a7 Merge pull request #533 from lanedirt/530-update-sonarcloud-github-action-to-work-with-pr-from-forks
Add pullrequest key to sonarcloud analysis
2025-01-14 10:49:48 +00:00
Leendert de Borst
837b16d971 Add pullrequest key to sonarcloud analysis (#530) 2025-01-14 11:49:04 +01:00
Leendert de Borst
4010d1b93f Merge pull request #531 from lanedirt/530-update-sonarcloud-github-action-to-work-with-pr-from-forks
Update sonarcloud-code-analysis.yml to work with PR's from forks
2025-01-14 10:28:48 +00:00
Leendert de Borst
f7ce60ae68 Update sonarcloud-code-analysis.yml to work with PR's from forks (#530) 2025-01-14 11:26:37 +01:00
Leendert de Borst
5e61bd5db2 Merge pull request #527 from lanedirt/525-prevent-email-address-collision-from-occuring
Prevent email address collision from occurring during identity generation
2025-01-13 14:09:54 +00:00
dependabot[bot]
a2e8a438de Bump NUnit.Analyzers from 4.5.0 to 4.6.0
Bumps [NUnit.Analyzers](https://github.com/nunit/nunit.analyzers) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/nunit/nunit.analyzers/releases)
- [Changelog](https://github.com/nunit/nunit.analyzers/blob/master/CHANGES.md)
- [Commits](https://github.com/nunit/nunit.analyzers/compare/4.5.0...4.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-13 14:09:38 +00:00
Leendert de Borst
92904dcf55 Refactor email prefix exists check (#525) 2025-01-12 15:29:15 +00:00
Leendert de Borst
e4f2ca630b Add server side email prefix generation method (#525) 2025-01-12 14:01:48 +00:00
Leendert de Borst
ed80ad24c1 Add more names to identity generator dictionary to prevent collisions (#525) 2025-01-12 13:02:22 +00:00
Leendert de Borst
0c368ab84b Merge pull request #524 from lanedirt/151-make-email-attachments-visible-in-ui-and-allow-for-downloading-attachments-through-client
Add email attachment support
2025-01-11 16:15:49 +00:00
Leendert de Borst
dee2044ed6 Refactor (#151) 2025-01-11 16:05:32 +00:00
dependabot[bot]
f6f6072b3f Bump coverlet.msbuild from 6.0.2 to 6.0.3
Bumps [coverlet.msbuild](https://github.com/coverlet-coverage/coverlet) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/coverlet-coverage/coverlet/releases)
- [Commits](https://github.com/coverlet-coverage/coverlet/compare/v6.0.2...v6.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-11 15:57:26 +00:00
dependabot[bot]
4bfe72d750 Bump coverlet.collector from 6.0.2 to 6.0.3
Bumps [coverlet.collector](https://github.com/coverlet-coverage/coverlet) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/coverlet-coverage/coverlet/releases)
- [Commits](https://github.com/coverlet-coverage/coverlet/compare/v6.0.2...v6.0.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-11 15:56:56 +00:00
Leendert de Borst
330f59dc10 Add email decryption test with and without attachments (#151) 2025-01-11 15:45:54 +00:00
Leendert de Borst
a20d981427 Add full attachment download flow to email decryption test (#151) 2025-01-11 13:10:26 +00:00
Leendert de Borst
bd2274db75 Add support for download attachments from SpamOK (#151) 2025-01-11 12:58:40 +00:00
Leendert de Borst
6cfa6f4ef5 Add email attachment retrieval and decryption to client (#151) 2025-01-11 12:49:35 +00:00
Leendert de Borst
8a40d2b1b9 Add attachment encryption assert to test (#151) 2025-01-10 21:35:22 +00:00
Leendert de Borst
237958ba0f Show attachment metadata in email modal popup (#151) 2025-01-10 19:13:18 +00:00
Leendert de Borst
79db3a54c7 Tweak client z-index to show emailmodal on top (#151) 2025-01-10 19:12:45 +00:00
Leendert de Borst
2029745f8b Fix tablet view for credential view page width (#151) 2025-01-10 18:54:51 +00:00
Leendert de Borst
ea4d498502 Update sendEmailCLI.sh to support sending attachments (#151) 2025-01-10 15:05:17 +00:00
Leendert de Borst
05838f5dca Add attachment indicator E2E test on email page (#151) 2025-01-10 14:55:45 +00:00
Leendert de Borst
79872163e2 Add attachment indicator to email page (#151) 2025-01-10 14:55:27 +00:00
Leendert de Borst
35d0f77dd6 Add HasAttachments to mailbox api model (#151) 2025-01-10 14:29:17 +00:00
Leendert de Borst
6660cd20bd Update docker-compose-pull.yml (#522) 2025-01-08 14:28:34 +00:00
Ikko Eltociear Ashimine
e236ba454f chore: update UserEmailClaim.cs (#521)
adress -> address
2025-01-08 14:13:14 +00:00
Leendert de Borst
6ec66e4d64 Merge pull request #517 from lanedirt/516-optimize-local-build-for-arm-devices
Add support for arm64 to docker images and install.sh local build
2025-01-04 09:37:00 +01:00
Leendert de Borst
14898c0c83 Refactor Dockerfile for readability (#516) 2025-01-04 09:24:05 +01:00
Leendert de Borst
d08bec9df7 Bump version to 0.10.3 (#516) 2025-01-04 01:31:07 +01:00
Leendert de Borst
9107dfa789 Update Docker images to also build for linux/arm64 (#516) 2025-01-04 01:04:57 +01:00
Leendert de Borst
351f6f4d16 Update install.sh (#516) 2025-01-03 23:55:51 +01:00
Leendert de Borst
aca607e579 Merge pull request #515 from lanedirt/514-prepare-0102-release
Bump version to 0.10.2
2025-01-03 22:30:47 +01:00
Leendert de Borst
ed053422ba Update StatusHostedService.cs (#512) 2025-01-03 22:15:27 +01:00
Leendert de Borst
955b8638ce Bump version (#514) 2025-01-03 21:50:14 +01:00
Leendert de Borst
1d8883cc94 Merge pull request #513 from lanedirt/512-task-runner-cleanup-jobs-do-not-run
Maintenance tasks do not run after migration to PostgreSQL
2025-01-03 21:45:04 +01:00
Leendert de Borst
48281f92e6 Refactor to reduce complexity (#512) 2025-01-03 21:29:42 +01:00
Leendert de Borst
f19db2c010 Refactor StatusWorker to prevent race conditions and improve stability (#512) 2025-01-03 20:38:13 +01:00
Leendert de Borst
f0d397c8af Add cancellation token check to worker start and stop wait (#512) 2025-01-03 16:18:37 +01:00
Leendert de Borst
fafa51d787 Update integration tests (#512) 2025-01-03 16:08:09 +01:00
Leendert de Borst
202151e4f1 Update SmtpServer TestHostBuilder to be compatible with integration and E2E tests (#512) 2025-01-03 15:36:09 +01:00
Leendert de Borst
c123edccd4 Refactor integration test TestHostBuilder setup to shared abstract class (#512) 2025-01-03 15:22:47 +01:00
Leendert de Borst
50cab3a2f3 Show full error when maintenance task fails to start (#512) 2025-01-03 12:44:55 +01:00
Leendert de Borst
0184e32e6d Update migration to reset task runner job sequence (#512) 2025-01-03 12:44:40 +01:00
Leendert de Borst
d73d4e90e0 Make admin password for dev always override existing password (#512) 2025-01-03 11:58:33 +01:00
Leendert de Borst
06d38842f5 Add dev database import/export support (#512) 2025-01-03 11:56:55 +01:00
Leendert de Borst
b0748316ff Merge pull request #511 from lanedirt/510-admin-password-hash-method-in-installcli-does-not-pass-required-arguments
Admin password hash method in installcli does not pass required arguments
2025-01-01 16:18:36 +01:00
Leendert de Borst
8f8b4af3c9 Update install.sh (#510) 2025-01-01 16:18:15 +01:00
Leendert de Borst
11bf183cbb Update install.sh (#510) 2025-01-01 16:17:41 +01:00
62 changed files with 2113 additions and 601 deletions

View File

@@ -20,8 +20,16 @@ jobs:
- name: Get repository and branch information
id: repo-info
run: |
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
# Check if this is a PR from a fork
if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
# If PR is from a fork, use main branch from lanedirt/AliasVault
echo "REPO_FULL_NAME=lanedirt/AliasVault" >> $GITHUB_ENV
echo "BRANCH_NAME=main" >> $GITHUB_ENV
else
# Otherwise use the current repository and branch
echo "REPO_FULL_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV
echo "BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV
fi
- name: Download install script from current branch
run: |
@@ -34,10 +42,23 @@ jobs:
echo "SMTP_PORT=2525" > .env
- name: Set permissions and run install.sh
id: install_script
continue-on-error: true
run: |
chmod +x install.sh
./install.sh install --verbose
- name: Check if failure was due to version mismatch
if: steps.install_script.outcome == 'failure'
run: |
if grep -q "Install script needs updating to match version" <<< "$(./install.sh install --verbose 2>&1)"; then
echo "Test skipped: Install script version is newer than latest release version. This is expected behavior if the install script is run on a branch that is ahead of the latest release."
exit 0
else
echo "Test failed due to an unexpected error"
exit 1
fi
- name: Set up Docker Compose
run: docker compose -f docker-compose.yml up -d

View File

@@ -21,6 +21,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Convert repository name to lowercase
run: |
echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
@@ -43,6 +49,7 @@ jobs:
with:
context: .
file: src/Databases/AliasServerDb/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-postgres:${{ github.ref_name }}
@@ -51,6 +58,7 @@ jobs:
with:
context: .
file: src/AliasVault.Api/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-api:${{ github.ref_name }}
@@ -59,6 +67,7 @@ jobs:
with:
context: .
file: src/AliasVault.Client/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-client:${{ github.ref_name }}
@@ -67,6 +76,7 @@ jobs:
with:
context: .
file: src/AliasVault.Admin/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-admin:${{ github.ref_name }}
@@ -75,6 +85,7 @@ jobs:
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-reverse-proxy:${{ github.ref_name }}
@@ -83,6 +94,7 @@ jobs:
with:
context: .
file: src/Services/AliasVault.SmtpService/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:${{ github.ref_name }}
@@ -91,6 +103,7 @@ jobs:
with:
context: .
file: src/Services/AliasVault.TaskRunner/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:${{ github.ref_name }}
@@ -99,5 +112,6 @@ jobs:
with:
context: .
file: src/Utilities/AliasVault.InstallCli/Dockerfile
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-installcli:${{ github.ref_name }}

View File

@@ -1,10 +1,13 @@
# This workflow will perform a SonarCloud code analysis on every push to the main branch or when a pull request is opened, synchronized, or reopened.
# This workflow will perform a SonarCloud code analysis on every push to the main branch or
# when a pull request is opened, synchronized, or reopened. The "pull_request_target" event is
# used to ensure that the analysis is done on the source branch of the pull request which has
# access to the SonarCloud token secret.
name: SonarCloud code analysis
on:
push:
branches:
- main
pull_request:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
build:
@@ -23,11 +26,13 @@ jobs:
uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'zulu' # Alternative distribution options are available.
distribution: 'zulu'
- uses: actions/checkout@v3
- name: Checkout code of PR branch
uses: actions/checkout@v3
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Cache SonarCloud packages
uses: actions/cache@v3
@@ -57,7 +62,11 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
shell: powershell
run: |
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
if ('${{ github.event_name }}' -eq 'pull_request_target') {
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
} else {
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
}
dotnet build
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}"

3
.gitignore vendored
View File

@@ -415,3 +415,6 @@ docs/.bundle
# Database files
database/postgres
database/postgres-dev
# Temp files
temp

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# @version 0.10.0
# @version 0.10.3
# Repository information used for downloading files and images from GitHub
REPO_OWNER="lanedirt"
@@ -60,6 +60,7 @@ show_usage() {
printf "Options:\n"
printf " --verbose Show detailed output\n"
printf " -y, --yes Automatic yes to prompts\n"
printf " --dev Target development database for db import/export operations\n"
printf " --help Show this help message\n"
printf "\n"
@@ -71,6 +72,7 @@ parse_args() {
VERBOSE=false
FORCE_YES=false
COMMAND_ARG=""
DEV_DB=false
if [ $# -eq 0 ]; then
show_usage
@@ -189,6 +191,10 @@ parse_args() {
FORCE_YES=true
shift
;;
--dev)
DEV_DB=true
shift
;;
*)
echo "Unknown option: $1"
show_usage
@@ -505,8 +511,9 @@ generate_admin_password() {
printf "${CYAN}> Generating admin password...${NC}\n"
PASSWORD=$(openssl rand -base64 12)
if ! docker pull ${GITHUB_CONTAINER_REGISTRY}-installcli:latest > /dev/null 2>&1; then
printf "${YELLOW}> Pre-built image not found, building locally...${NC}"
# Build locally if in build mode or if pre-built image is not available
if grep -q "^DEPLOYMENT_MODE=build" "$ENV_FILE" 2>/dev/null || ! docker pull ${GITHUB_CONTAINER_REGISTRY}-installcli:latest > /dev/null 2>&1; then
printf "${CYAN}> Building InstallCli locally...${NC}"
if [ "$VERBOSE" = true ]; then
docker build -t installcli -f src/Utilities/AliasVault.InstallCli/Dockerfile .
else
@@ -526,24 +533,19 @@ generate_admin_password() {
fi
)
fi
HASH=$(docker run --rm installcli "$PASSWORD")
if [ -z "$HASH" ]; then
printf "${RED}> Error: Failed to generate password hash${NC}\n"
exit 1
fi
HASH=$(docker run --rm installcli hash-password "$PASSWORD")
else
HASH=$(docker run --rm ${GITHUB_CONTAINER_REGISTRY}-installcli:latest "$PASSWORD")
if [ -z "$HASH" ]; then
printf "${RED}> Error: Failed to generate password hash${NC}\n"
exit 1
fi
HASH=$(docker run --rm ${GITHUB_CONTAINER_REGISTRY}-installcli:latest hash-password "$PASSWORD")
fi
if [ -n "$HASH" ]; then
update_env_var "ADMIN_PASSWORD_HASH" "$HASH"
update_env_var "ADMIN_PASSWORD_GENERATED" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
printf " ==> New admin password: $PASSWORD\n"
if [ -z "$HASH" ]; then
printf "${RED}> Error: Failed to generate password hash${NC}\n"
exit 1
fi
update_env_var "ADMIN_PASSWORD_HASH" "$HASH"
update_env_var "ADMIN_PASSWORD_GENERATED" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
printf " ==> New admin password: $PASSWORD\n"
}
# Function to set default ports
@@ -1749,7 +1751,7 @@ handle_migrate_db() {
printf "${CYAN}> Stopping services to ensure database is not in use...${NC}\n"
docker compose stop api admin task-runner smtp
if ! docker pull ${GITHUB_CONTAINER_REGISTRY}-installcli:0.10.0 > /dev/null 2>&1; then
if ! docker pull ${GITHUB_CONTAINER_REGISTRY}-installcli:0.10.3 > /dev/null 2>&1; then
printf "${YELLOW}> Pre-built image not found, building locally...${NC}"
if [ "$VERBOSE" = true ]; then
docker build -t installcli -f src/Utilities/AliasVault.InstallCli/Dockerfile .
@@ -1810,30 +1812,48 @@ handle_db_export() {
# Check if output redirection is present
if [ -t 1 ]; then
printf "${RED}Error: Output redirection is required.${NC}\n" >&2
printf "Usage: ./install.sh db-export > backup.sql.gz\n" >&2
printf "Usage: ./install.sh db-export [--dev] > backup.sql.gz\n" >&2
printf "\n" >&2
printf "Options:\n" >&2
printf " --dev Export from development database\n" >&2
printf "\n" >&2
printf "Example:\n" >&2
printf " ./install.sh db-export > my_backup_$(date +%Y%m%d).sql.gz\n" >&2
printf " ./install.sh db-export --dev > my_dev_backup_$(date +%Y%m%d).sql.gz\n" >&2
exit 1
fi
# Check if containers are running
if ! docker compose ps --quiet 2>/dev/null | grep -q .; then
printf "${RED}Error: AliasVault containers are not running. Start them first with: ./install.sh start${NC}\n" >&2
exit 1
if [ "$DEV_DB" = true ]; then
# Check if dev containers are running
if ! docker compose -f docker-compose.dev.yml -p aliasvault-dev ps postgres-dev --quiet 2>/dev/null | grep -q .; then
printf "${RED}Error: Development database container is not running. Start it first with: ./install.sh configure-dev-db${NC}\n" >&2
exit 1
fi
# Check if postgres-dev container is healthy
if ! docker compose -f docker-compose.dev.yml -p aliasvault-dev ps postgres-dev | grep -q "healthy"; then
printf "${RED}Error: Development PostgreSQL container is not healthy. Please check the logs.${NC}\n" >&2
exit 1
fi
printf "${CYAN}> Exporting development database...${NC}\n" >&2
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec postgres-dev pg_dump -U aliasvault aliasvault | gzip
else
# Production database export logic
if ! docker compose ps --quiet 2>/dev/null | grep -q .; then
printf "${RED}Error: AliasVault containers are not running. Start them first with: ./install.sh start${NC}\n" >&2
exit 1
fi
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy. Please check the logs with: docker compose logs postgres${NC}\n" >&2
exit 1
fi
printf "${CYAN}> Exporting production database...${NC}\n" >&2
docker compose exec postgres pg_dump -U aliasvault aliasvault | gzip
fi
# Check if postgres container is healthy
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy. Please check the logs with: docker compose logs postgres${NC}\n" >&2
exit 1
fi
printf "${CYAN}> Exporting database...${NC}\n" >&2
# Only the actual pg_dump output goes to stdout, everything else to stderr
docker compose exec postgres pg_dump -U aliasvault aliasvault | gzip
if [ $? -eq 0 ]; then
printf "${GREEN}> Database exported successfully.${NC}\n" >&2
else
@@ -1847,22 +1867,36 @@ handle_db_import() {
printf "${YELLOW}+++ Importing Database +++${NC}\n"
# Check if containers are running
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy.${NC}\n"
exit 1
if [ "$DEV_DB" = true ]; then
if ! docker compose -f docker-compose.dev.yml -p aliasvault-dev ps postgres-dev | grep -q "healthy"; then
printf "${RED}Error: Development PostgreSQL container is not healthy.${NC}\n"
exit 1
fi
else
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy.${NC}\n"
exit 1
fi
fi
# Check if we're getting input from a pipe
if [ -t 0 ]; then
printf "${RED}Error: No input file provided${NC}\n"
printf "Usage: ./install.sh db-import < backup.sql.gz\n"
printf "Usage: ./install.sh db-import [--dev] < backup.sql.gz\n"
exit 1
fi
# Save stdin to file descriptor 3
exec 3<&0
printf "${RED}Warning: This will DELETE ALL EXISTING DATA in the database.${NC}\n"
printf "${RED}Warning: This will DELETE ALL EXISTING DATA in the "
if [ "$DEV_DB" = true ]; then
printf "development database"
else
printf "database"
fi
printf ".${NC}\n"
if [ "$FORCE_YES" != true ]; then
# Use /dev/tty to read from terminal even when stdin is redirected
if [ -t 1 ] && [ -t 2 ] && [ -e /dev/tty ]; then
@@ -1882,14 +1916,20 @@ handle_db_import() {
fi
fi
printf "${CYAN}> Stopping dependent services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose stop api admin task-runner smtp
else
docker compose stop api admin task-runner smtp > /dev/null 2>&1
if [ "$DEV_DB" != true ]; then
printf "${CYAN}> Stopping dependent services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose stop api admin task-runner smtp
else
docker compose stop api admin task-runner smtp > /dev/null 2>&1
fi
fi
printf "${CYAN}> Importing database...${NC}\n"
printf "${CYAN}> Importing "
if [ "$DEV_DB" = true ]; then
printf "development "
fi
printf "database...${NC}\n"
# Create a temporary file to verify the gzip input
temp_file=$(mktemp)
@@ -1902,18 +1942,30 @@ handle_db_import() {
exit 1
fi
if [ "$VERBOSE" = true ]; then
# Proceed with import
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault
if [ "$DEV_DB" = true ]; then
if [ "$VERBOSE" = true ]; then
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" && \
gunzip -c "$temp_file" | docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault aliasvault
else
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" > /dev/null 2>&1 && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" > /dev/null 2>&1 && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" > /dev/null 2>&1 && \
gunzip -c "$temp_file" | docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault aliasvault > /dev/null 2>&1
fi
else
# Suppress all output except errors
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" > /dev/null 2>&1 && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault > /dev/null 2>&1
if [ "$VERBOSE" = true ]; then
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault
else
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" > /dev/null 2>&1 && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault > /dev/null 2>&1
fi
fi
import_status=$?
@@ -1921,11 +1973,13 @@ handle_db_import() {
if [ $import_status -eq 0 ]; then
printf "${GREEN}> Database imported successfully.${NC}\n"
printf "${CYAN}> Starting services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose restart api admin task-runner smtp reverse-proxy
else
docker compose restart api admin task-runner smtp reverse-proxy > /dev/null 2>&1
if [ "$DEV_DB" != true ]; then
printf "${CYAN}> Starting services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose restart api admin task-runner smtp reverse-proxy
else
docker compose restart api admin task-runner smtp reverse-proxy > /dev/null 2>&1
fi
fi
else
printf "${RED}> Import failed. Please check that your backup file is valid.${NC}\n"

View File

@@ -2,15 +2,19 @@
WORKDIR /app
EXPOSE 3002
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/AliasVault.Admin/AliasVault.Admin.csproj", "src/AliasVault.Admin/"]
RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj"
RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj" -a "$TARGETARCH"
COPY . .
WORKDIR "/src/src/AliasVault.Admin"
RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" \
-a "$TARGETARCH" \
-o /app/publish \
/p:UseAppHost=false
FROM base AS final
WORKDIR /app

View File

@@ -5,10 +5,10 @@
{
<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)">
disabled="@(!service.IsHeartBeatValid)"
title="@GetButtonTooltip(service)">
<span>@service.DisplayName</span>
@if (service.IsPending)
@if (service.IsHeartBeatValid && service.CurrentStatus != service.DesiredStatus && !string.IsNullOrEmpty(service.DesiredStatus))
{
<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>
@@ -54,9 +54,9 @@
{
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; }
public string CurrentStatus { get; set; } = "";
public string DesiredStatus { get; set; } = "";
public bool IsHeartBeatValid { get; set; }
}
private List<ServiceState> Services { get; set; } = [];
@@ -112,15 +112,23 @@
{
string buttonClass = "cursor-pointer ";
if (!IsHeartbeatValid(service.LastHeartbeat))
if (!service.IsHeartBeatValid)
{
buttonClass += "bg-gray-600";
}
else if (service.Status)
else if (service.CurrentStatus == "Started" && (service.DesiredStatus == string.Empty || service.DesiredStatus == "Started"))
{
buttonClass += "bg-green-600";
}
else
else if (service.CurrentStatus == "Stopping" || (service.DesiredStatus == "Stopped" && service.CurrentStatus != service.DesiredStatus))
{
buttonClass += "bg-red-500";
}
else if (service.CurrentStatus == "Starting" || (service.DesiredStatus == "Started" && service.CurrentStatus != service.DesiredStatus))
{
buttonClass += "bg-emerald-500";
}
else if (service.DesiredStatus == "Stopped" && (service.DesiredStatus == string.Empty || service.DesiredStatus == "Stopped"))
{
buttonClass += "bg-red-600";
}
@@ -131,9 +139,22 @@
/// <summary>
/// Gets the tooltip text for a service button based on its last heartbeat.
/// </summary>
private static string GetButtonTooltip(DateTime lastHeartbeat)
private static string GetButtonTooltip(ServiceState service)
{
return IsHeartbeatValid(lastHeartbeat) ? "" : "Heartbeat offline";
if (!service.IsHeartBeatValid)
{
return "Heartbeat offline";
}
var statusMessages = new Dictionary<string, string>
{
{ "Started", "Service is running" },
{ "Starting", "Service is starting..." },
{ "Stopped", "Service is stopped" },
{ "Stopping", "Service is stopping..." }
};
return statusMessages.GetValueOrDefault(service.CurrentStatus, string.Empty);
}
/// <summary>
@@ -143,18 +164,25 @@
{
var service = Services.First(s => s.Name == serviceName);
if (!IsHeartbeatValid(service.LastHeartbeat))
if (!service.IsHeartBeatValid)
{
return;
}
service.IsPending = true;
// If service not started and not starting, clicking should start it. Otherwise, stop it.
if (service.CurrentStatus == "Started" || service.DesiredStatus == "Started")
{
service.DesiredStatus = "Stopped";
}
else
{
service.DesiredStatus = "Started";
}
StateHasChanged();
service.Status = !service.Status;
await UpdateServiceStatus(serviceName, service.Status);
await UpdateServiceStatus(serviceName, service.DesiredStatus);
service.CurrentStatus = service.DesiredStatus;
service.IsPending = false;
StateHasChanged();
}
@@ -163,7 +191,7 @@
/// </summary>
private async Task InitPage()
{
if (InitInProgress || Services.Any(s => s.IsPending))
if (InitInProgress)
{
return;
}
@@ -179,8 +207,9 @@
var entry = ServiceStatus.Find(x => x.ServiceName == service.Name);
if (entry != null)
{
service.LastHeartbeat = entry.Heartbeat;
service.Status = IsHeartbeatValid(service.LastHeartbeat) && entry.CurrentStatus == "Started";
service.IsHeartBeatValid = IsHeartbeatValid(entry.Heartbeat);
service.CurrentStatus = entry.CurrentStatus;
service.DesiredStatus = entry.DesiredStatus;
}
}
@@ -195,14 +224,13 @@
/// <summary>
/// Updates the status of a service.
/// </summary>
private async Task<bool> UpdateServiceStatus(string serviceName, bool newStatus)
private async Task<bool> UpdateServiceStatus(string serviceName, string desiredStatus)
{
await using 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;
entry.DesiredStatus = desiredStatus;
await dbContext.SaveChangesAsync();
var timeout = DateTime.UtcNow.AddSeconds(30);
@@ -215,7 +243,7 @@
await using var dbContextInner = await DbContextFactory.CreateDbContextAsync();
var check = await dbContextInner.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
if (check.CurrentStatus == newDesiredStatus)
if (check.CurrentStatus == entry.DesiredStatus)
{
return true;
}

View File

@@ -26,17 +26,23 @@
private RegistrationStatisticsCard? _registrationStatisticsCard;
private EmailStatisticsCard? _emailStatisticsCard;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// 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);
}
}
/// <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();
}
}

View File

@@ -162,7 +162,7 @@
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Failed to start maintenance tasks: {ex.Message}", true);
GlobalNotificationService.AddErrorMessage($"Failed to start maintenance tasks: {ex}", true);
}
}

View File

@@ -17,7 +17,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z",
"ADMIN_PASSWORD_GENERATED": "2030-01-01T00:00:00Z",
"DATA_PROTECTION_CERT_PASS": "Development"
}
},

View File

@@ -670,6 +670,10 @@ video {
margin-left: auto;
}
.mr-1 {
margin-right: 0.25rem;
}
.mr-14 {
margin-right: 3.5rem;
}
@@ -1148,6 +1152,11 @@ video {
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-emerald-500 {
--tw-bg-opacity: 1;
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -1498,10 +1507,6 @@ video {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.leading-6 {
line-height: 1.5rem;
}

View File

@@ -34,6 +34,7 @@
<ItemGroup>
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Generators\AliasVault.Generators.Identity\AliasVault.Generators.Identity.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />

View File

@@ -105,10 +105,10 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
}
/// <summary>
/// Returns a list of emails for the provided email address.
/// Returns a list of emails for the provided list of email addresses.
/// </summary>
/// <param name="model">The request model extracted from POST body.</param>
/// <returns>List of aliases in JSON format.</returns>
/// <returns>List of emails in JSON format.</returns>
[HttpPost(template: "bulk", Name = "GetEmailBoxBulk")]
public async Task<IActionResult> GetEmailBoxBulk([FromBody] MailboxBulkRequest model)
{
@@ -154,6 +154,7 @@ public class EmailBoxController(IAliasServerDbContextFactory dbContextFactory, U
MessagePreview = x.MessagePreview ?? string.Empty,
EncryptedSymmetricKey = x.EncryptedSymmetricKey,
EncryptionKey = x.EncryptionKey.PublicKey,
HasAttachments = x.Attachments.Any(),
})
.OrderByDescending(x => x.DateSystem)
.Skip((model.Page - 1) * model.PageSize)

View File

@@ -25,7 +25,7 @@ using Microsoft.EntityFrameworkCore;
public class EmailController(ILogger<VaultController> logger, IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Get the newest version of the vault for the current user.
/// Get the email with the specified ID.
/// </summary>
/// <param name="id">The email ID to open.</param>
/// <returns>List of aliases in JSON format.</returns>
@@ -105,6 +105,36 @@ public class EmailController(ILogger<VaultController> logger, IAliasServerDbCont
}
}
/// <summary>
/// Get the attachment bytes for the specified email and attachment ID.
/// </summary>
/// <param name="id">The email ID.</param>
/// <param name="attachmentId">The attachment ID.</param>
/// <returns>Attachment bytes in encrypted form.</returns>
[HttpGet(template: "{id}/attachments/{attachmentId}", Name = "GetEmailAttachment")]
public async Task<IActionResult> GetEmailAttachment(int id, int attachmentId)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var (email, errorResult) = await AuthenticateAndRetrieveEmailAsync(id, context);
if (errorResult != null)
{
return errorResult;
}
// Find the requested attachment
var attachment = await context.EmailAttachments
.FirstOrDefaultAsync(x => x.Id == attachmentId && x.EmailId == email!.Id);
if (attachment == null)
{
return NotFound("Attachment not found.");
}
// Return the encrypted bytes
return File(attachment.Bytes, attachment.MimeType, attachment.Filename);
}
/// <summary>
/// Authenticates the user and retrieves the requested email.
/// </summary>

View File

@@ -0,0 +1,58 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityController.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.Api.Controllers;
using AliasServerDb;
using AliasVault.Api.Controllers.Abstracts;
using AliasVault.Api.Helpers;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Controller for generating identities taking into account existing information on the AliasVault server.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
/// <param name="dbContextFactory">DbContextFactory instance.</param>
[ApiVersion("1")]
public class IdentityController(UserManager<AliasVaultUser> userManager, IAliasServerDbContextFactory dbContextFactory) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Verify that provided email address is not already taken by another user.
/// </summary>
/// <param name="email">The full email address to check.</param>
/// <returns>True if the email address is already taken, false otherwise.</returns>
[HttpPost("CheckEmail/{email}")]
public async Task<IActionResult> CheckEmail(string email)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
bool isTaken = await EmailClaimExistsAsync(email);
return Ok(new { isTaken });
}
/// <summary>
/// Verify that provided email address is not already taken by another user.
/// </summary>
/// <param name="email">The email address to check.</param>
/// <returns>True if the email address is already taken, false otherwise.</returns>
private async Task<bool> EmailClaimExistsAsync(string email)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var sanitizedEmail = EmailHelper.SanitizeEmail(email);
var claimExists = await context.UserEmailClaims.FirstOrDefaultAsync(c => c.Address == sanitizedEmail);
return claimExists != null;
}
}

View File

@@ -240,7 +240,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
// Update user email claims if email addresses have been supplied.
if (model.EmailAddressList.Count > 0)
{
await UpdateUserEmailClaims(context, user.Id, model.EmailAddressList);
await UpdateUserEmailClaims(context, user, model.EmailAddressList);
}
// Sync user public key if supplied.
@@ -371,14 +371,14 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
/// Updates the user's email claims based on the provided email address list.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="userId">The ID of the user.</param>
/// <param name="user">The user object.</param>
/// <param name="newEmailAddresses">The list of new email addresses to claim.</param>
/// <returns>A task representing the asynchronous operation.</returns>
private async Task UpdateUserEmailClaims(AliasServerDbContext context, string userId, List<string> newEmailAddresses)
private async Task UpdateUserEmailClaims(AliasServerDbContext context, AliasVaultUser user, List<string> newEmailAddresses)
{
// Get all existing user email claims.
var existingEmailClaims = await context.UserEmailClaims
.Where(x => x.UserId == userId)
.Where(x => x.UserId == user.Id)
.Select(x => x.Address)
.ToListAsync();
@@ -386,7 +386,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
foreach (var email in newEmailAddresses)
{
// Sanitize email address.
var sanitizedEmail = email.Trim().ToLower();
var sanitizedEmail = EmailHelper.SanitizeEmail(email);
// If email address is invalid according to the EmailAddressAttribute, skip it.
if (!new EmailAddressAttribute().IsValid(sanitizedEmail))
@@ -398,10 +398,10 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
var existingClaim = await context.UserEmailClaims
.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
if (existingClaim != null && existingClaim.UserId != userId)
if (existingClaim != null && existingClaim.UserId != user.Id)
{
// Email address is already claimed by another user. Log the error and continue.
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", userId, sanitizedEmail);
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", user.UserName, sanitizedEmail);
continue;
}
@@ -411,7 +411,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
{
context.UserEmailClaims.Add(new UserEmailClaim
{
UserId = userId,
UserId = user.Id,
Address = sanitizedEmail,
AddressLocal = sanitizedEmail.Split('@')[0],
AddressDomain = sanitizedEmail.Split('@')[1],
@@ -422,7 +422,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
catch (DbUpdateException ex)
{
// Error while adding email claim. Log the error and continue.
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, userId);
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, user.UserName);
}
}
}

View File

@@ -1,19 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 3001
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy the project files and restore dependencies
COPY ["src/AliasVault.Api/AliasVault.Api.csproj", "src/AliasVault.Api/"]
RUN dotnet restore "src/AliasVault.Api/AliasVault.Api.csproj"
RUN dotnet restore "src/AliasVault.Api/AliasVault.Api.csproj" -a "$TARGETARCH"
COPY . .
# Build and publish
WORKDIR "/src/src/AliasVault.Api"
RUN dotnet publish "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
RUN dotnet publish "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" \
-a "$TARGETARCH" \
-o /app/publish \
/p:UseAppHost=false
FROM base AS final
# Final stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="EmailHelper.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.Api.Helpers;
/// <summary>
/// EmailHelper class which contains helper methods for email.
/// </summary>
public static class EmailHelper
{
/// <summary>
/// Sanitize email address by trimming and converting to lowercase.
/// </summary>
/// <param name="email">Email address to sanitize.</param>
/// <returns>Sanitized email address.</returns>
public static string SanitizeEmail(string email)
{
return email.Trim().ToLower();
}
}

View File

@@ -1,7 +1,8 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV MSBUILDDEBUGPATH=/src/msbuild-logs
@@ -12,26 +13,29 @@ RUN mkdir -p /src/msbuild-logs
# Install Python which is required by the WebAssembly tools
RUN apt-get update && apt-get install -y python3 && apt-get clean
# Create the debug directory and install Python which is required by the WebAssembly tools
RUN mkdir -p /src/msbuild-logs && apt-get update && apt-get install -y python3 && apt-get clean
# Install the WebAssembly tools
RUN dotnet workload install wasm-tools
# Copy the project files and restore dependencies
COPY ["src/AliasVault.Client/AliasVault.Client.csproj", "src/AliasVault.Client/"]
RUN dotnet restore "src/AliasVault.Client/AliasVault.Client.csproj"
RUN dotnet restore "src/AliasVault.Client/AliasVault.Client.csproj" -a "$TARGETARCH"
COPY . .
# Build the Client project
WORKDIR "/src/src/AliasVault.Client"
RUN dotnet build "AliasVault.Client.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
RUN dotnet build "AliasVault.Client.csproj" \
-c "$BUILD_CONFIGURATION" \
-o /app/build \
-a "$TARGETARCH"
# Publish the Client project
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
ARG TARGETARCH
RUN dotnet publish "AliasVault.Client.csproj" \
-c "$BUILD_CONFIGURATION" \
-a "$TARGETARCH" \
--no-restore \
-o /app/publish \
/p:UseAppHost=false \

View File

@@ -3,42 +3,72 @@
@inject JsInteropService JsInteropService
@inject GlobalNotificationService GlobalNotificationService
@inject IHttpClientFactory HttpClientFactory
@inject EmailService EmailService
@inject HttpClient HttpClient
<ClickOutsideHandler OnClose="OnClose" ContentId="emailModal">
<div class="fixed inset-0 z-50 overflow-auto bg-gray-500 bg-opacity-75 flex items-center justify-center">
<div id="emailModal" class="relative p-8 bg-white w-3/4 flex-col flex rounded-lg shadow-xl">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900">
@if (IsSpamOk)
{
<a target="_blank" href="https://spamok.com/@(Email!.ToLocal)/@(Email!.Id)">@Email.Subject</a>
}
else
{
<span>@Email?.Subject</span>
}
</h2>
<button @onclick="Close" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mt-4">
<p class="text-sm text-gray-500 dark:text-gray-400">From: @(Email?.FromLocal)@@@(Email?.FromDomain)</p>
<p class="text-sm text-gray-500 dark:text-gray-400">To: @(Email?.ToLocal)@@@(Email?.ToDomain)</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Date: @Email?.DateSystem</p>
</div>
<div class="mt-4 text-gray-700 dark:text-gray-300">
<div>
<iframe class="w-full overscroll-y-auto" style="height:500px;" srcdoc="@EmailBody">
</iframe>
<div id="emailModal" class="relative bg-white w-3/4 flex flex-col rounded-lg shadow-xl max-h-[90vh]">
<!-- Header -->
<div class="p-4 border-b">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900">
@if (IsSpamOk)
{
<a target="_blank" href="https://spamok.com/@(Email!.ToLocal)/@(Email!.Id)">@Email.Subject</a>
}
else
{
<span>@Email?.Subject</span>
}
</h2>
<button @onclick="Close" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">From: @(Email?.FromLocal)@@@(Email?.FromDomain)</p>
<p class="text-sm text-gray-500 dark:text-gray-400">To: @(Email?.ToLocal)@@@(Email?.ToDomain)</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Date: @Email?.DateSystem</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-4">
<button id="delete-email" @onclick="DeleteEmail" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
<button id="close-email-modal" @onclick="Close" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Close</button>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-4">
<div class="text-gray-700 dark:text-gray-300">
<div>
<iframe class="w-full overscroll-y-auto" style="height:500px;" srcdoc="@EmailBody">
</iframe>
</div>
</div>
<div class="mt-4">
@if (Email?.Attachments?.Any() == true)
{
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Attachments:</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
@foreach (var attachment in Email.Attachments)
{
<div class="flex items-center space-x-2">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
<button @onclick="() => DownloadAttachment(attachment)"
class="text-primary hover:underline text-sm truncate attachment-link">
(@(Math.Ceiling((double)attachment.Filesize / 1024)) KB) @attachment.Filename
</button>
</div>
}
</div>
</div>
}
</div>
<div class="mt-6 flex justify-end space-x-4">
<button id="delete-email" @onclick="DeleteEmail" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
<button id="close-email-modal" @onclick="Close" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Close</button>
</div>
</div>
</div>
</div>
@@ -192,4 +222,56 @@
}
}
}
/// <summary>
/// Download an attachment.
/// </summary>
private async Task DownloadAttachment(AttachmentApiModel attachment)
{
try
{
if (IsSpamOk)
{
var client = HttpClientFactory.CreateClient("EmailClient");
var response = await client.GetAsync($"https://api.spamok.com/v2/Attachment/{Email!.Id}/{attachment.Id}/download");
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync();
await JsInteropService.DownloadFileFromStream(attachment.Filename, bytes);
}
else
{
GlobalNotificationService.AddErrorMessage("Failed to download attachment", true);
}
}
else
{
var response = await HttpClient.GetAsync($"v1/Email/{Email!.Id}/attachments/{attachment.Id}");
if (response.IsSuccessStatusCode)
{
// Get attachment bytes from API.
var bytes = await response.Content.ReadAsByteArrayAsync();
// Decrypt the attachment locally with email's encryption key.
var decryptedBytes = await EmailService.DecryptEmailAttachment(Email, bytes);
// Offer the decrypted attachment as download to the user's browser.
if (decryptedBytes != null)
{
await JsInteropService.DownloadFileFromStream(attachment.Filename, decryptedBytes);
}
}
else
{
GlobalNotificationService.AddErrorMessage("Failed to download attachment", true);
}
}
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Error downloading attachment: {ex.Message}", true);
}
}
}

View File

@@ -61,7 +61,7 @@
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
Date
</th>
</tr>
</thead>

View File

@@ -2,7 +2,7 @@
@using AliasVault.Shared.Core
@implements IDisposable
<footer class="relative lg:fixed bottom-0 left-0 right-0 dark:bg-gray-900 @(ShowBorder ? "border-t border-gray-200 dark:border-gray-700" : "")">
<footer class="relative -z-10 lg:fixed bottom-0 left-0 right-0 dark:bg-gray-900 @(ShowBorder ? "border-t border-gray-200 dark:border-gray-700" : "")">
<div class="container mx-auto px-4 py-4">
<div class="flex flex-col lg:flex-row justify-between items-center">
<p class="text-sm text-center text-gray-500 mb-4 lg:mb-0">

View File

@@ -8,8 +8,8 @@
<ConfirmModal />
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
<TopMenu />
<div class="flex pt-16 mb-4 lg:mb-16 overflow-hidden bg-gray-100 dark:bg-gray-900 relative z-20">
<div id="main-content" class="relative z-10 w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-100 dark:bg-gray-900">
<div class="flex pt-16 mb-4 lg:mb-16 overflow-hidden bg-gray-100 dark:bg-gray-900 relative">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-100 dark:bg-gray-900">
<main>
<GlobalNotificationDisplay />
@Body

View File

@@ -27,8 +27,8 @@ else
</CustomActions>
</PageHeader>
<div class="grid grid-cols-2 px-4 pt-6 md:grid-cols-3 lg:gap-4 dark:bg-gray-900">
<div class="col-span-full lg:col-auto">
<div class="grid grid-cols-1 px-4 pt-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-4 dark:bg-gray-900">
<div class="col-span-1 md:col-span-2 lg:col-span-1">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="items-center flex space-x-4">
<DisplayFavicon FaviconBytes="@Alias.Service.Logo" />
@@ -53,7 +53,7 @@ else
<AttachmentViewer Attachments="@Alias.Attachments" />
}
</div>
<div class="col-span-2">
<div class="col-span-1 md:col-span-2 lg:col-span-2">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-2 text-xl font-semibold dark:text-white">Login credentials</h3>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">

View File

@@ -66,8 +66,14 @@ else
<div class="flex-grow">
<div class="flex items-center justify-between mb-2 mr-4">
<div>
<div class="text-gray-800 dark:text-gray-200 mb-2">
<div class="text-gray-800 dark:text-gray-200 mb-2 flex items-center">
@email.Subject
@if (email.HasAttachments)
{
<svg class="attachment-indicator w-4 h-4 ml-2 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
</svg>
}
</div>
<div class="text-sm text-gray-400 dark:text-gray-100 line-clamp-2">
@email.MessagePreview
@@ -231,7 +237,8 @@ else
Subject = email.Subject,
MessagePreview = email.MessagePreview,
CredentialId = credentialInfo.Id,
CredentialName = credentialInfo.ServiceName
CredentialName = credentialInfo.ServiceName,
HasAttachments = email.HasAttachments,
};
}).ToList();

View File

@@ -56,4 +56,9 @@ public class MailListViewModel
/// Gets or sets the message preview.
/// </summary>
public string MessagePreview { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the email has attachments.
/// </summary>
public bool HasAttachments { get; set; }
}

View File

@@ -49,20 +49,43 @@ public sealed class CredentialService(HttpClient httpClient, DbService dbService
/// <returns>Task.</returns>
public async Task<Credential> GenerateRandomIdentity(Credential credential)
{
// Generate a random identity using the IIdentityGenerator implementation.
var identity = await IdentityGeneratorFactory.CreateIdentityGenerator(dbService.Settings.DefaultIdentityLanguage).GenerateRandomIdentityAsync();
const int MaxAttempts = 5;
var attempts = 0;
bool isEmailTaken;
// Generate random values for the Identity properties
credential.Username = identity.NickName;
credential.Alias.FirstName = identity.FirstName;
credential.Alias.LastName = identity.LastName;
credential.Alias.NickName = identity.NickName;
credential.Alias.Gender = identity.Gender == Gender.Male ? "Male" : "Female";
credential.Alias.BirthDate = identity.BirthDate;
do
{
// Generate a random identity using the IIdentityGenerator implementation
var identity = await IdentityGeneratorFactory.CreateIdentityGenerator(dbService.Settings.DefaultIdentityLanguage).GenerateRandomIdentityAsync();
// Set the email
var emailDomain = GetDefaultEmailDomain();
credential.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
// Generate random values for the Identity properties
credential.Username = identity.NickName;
credential.Alias.FirstName = identity.FirstName;
credential.Alias.LastName = identity.LastName;
credential.Alias.NickName = identity.NickName;
credential.Alias.Gender = identity.Gender == Gender.Male ? "Male" : "Female";
credential.Alias.BirthDate = identity.BirthDate;
// Set the email
var emailDomain = GetDefaultEmailDomain();
credential.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
// Check if email is already taken
try
{
var response = await httpClient.PostAsync($"v1/Identity/CheckEmail/{credential.Alias.Email}", null);
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, bool>>();
isEmailTaken = result?["isTaken"] ?? false;
}
catch
{
// If the API call fails, assume email is not taken to allow operation to continue
isEmailTaken = false;
}
attempts++;
}
while (isEmailTaken && attempts < MaxAttempts);
// Generate password
credential.Passwords.First().Value = GenerateRandomPassword();

View File

@@ -61,6 +61,31 @@ public sealed class EmailService(DbService dbService, JsInteropService jsInterop
return emailList;
}
/// <summary>
/// Decrypts an email attachment using the email's encryption key.
/// </summary>
/// <param name="email">The email containing the encryption information.</param>
/// <param name="encryptedBytes">The encrypted attachment bytes.</param>
/// <returns>Decrypted attachment bytes.</returns>
public async Task<byte[]?> DecryptEmailAttachment(EmailApiModel email, byte[] encryptedBytes)
{
await EnsureEncryptionKeys();
var privateKey = _encryptionKeys.First(x => x.PublicKey == email.EncryptionKey);
try
{
var decryptedSymmetricKey = await jsInteropService.DecryptWithPrivateKey(email.EncryptedSymmetricKey, privateKey.PrivateKey);
var decryptedBase64 = await jsInteropService.SymmetricDecryptBytes(encryptedBytes, Convert.ToBase64String(decryptedSymmetricKey));
return decryptedBase64;
}
catch (Exception ex)
{
globalNotificationService.AddErrorMessage(ex.Message, true);
logger.LogError(ex, "Error decrypting email attachment.");
return null;
}
}
/// <summary>
/// Decrypt the contents of a single email.
/// </summary>

View File

@@ -248,6 +248,23 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.registerVisibilityCallback", objRef);
/// <summary>
/// Symmetrically decrypts a byte array using the provided encryption key.
/// </summary>
/// <param name="cipherBytes">Cipher bytes to decrypt.</param>
/// <param name="encryptionKey">Encryption key to use.</param>
/// <returns>Decrypted bytes.</returns>
public async Task<byte[]> SymmetricDecryptBytes(byte[] cipherBytes, string encryptionKey)
{
if (cipherBytes == null || cipherBytes.Length == 0)
{
return [];
}
var base64Ciphertext = Convert.ToBase64String(cipherBytes);
return await jsRuntime.InvokeAsync<byte[]>("cryptoInterop.decryptBytes", base64Ciphertext, encryptionKey);
}
/// <summary>
/// Represents the result of a WebAuthn get credential operation.
/// </summary>

View File

@@ -690,6 +690,10 @@ video {
z-index: 50;
}
.-z-10 {
z-index: -10;
}
.col-span-1 {
grid-column: span 1 / span 1;
}
@@ -936,6 +940,10 @@ video {
height: 100%;
}
.max-h-\[90vh\] {
max-height: 90vh;
}
.min-h-screen {
min-height: 100vh;
}
@@ -1028,6 +1036,10 @@ video {
max-width: 36rem;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@@ -2280,6 +2292,11 @@ video {
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.dark\:border-primary-500:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.dark\:border-primary-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(184 112 47 / var(--tw-border-opacity));
@@ -2300,11 +2317,6 @@ video {
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.dark\:border-primary-500:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.dark\:bg-blue-800:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
@@ -2644,10 +2656,6 @@ video {
}
@media (min-width: 640px) {
.sm\:absolute {
position: absolute;
}
.sm\:-top-2 {
top: -0.5rem;
}
@@ -2680,6 +2688,10 @@ video {
width: auto;
}
.sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sm\:flex-row {
flex-direction: row;
}
@@ -2718,6 +2730,10 @@ video {
grid-column: span 1 / span 1;
}
.md\:col-span-2 {
grid-column: span 2 / span 2;
}
.md\:ml-2 {
margin-left: 0.5rem;
}

View File

@@ -1,6 +1,6 @@
/**
* AES (symmetric) encryption and decryption functions.
* @type {{encrypt: (function(*, *): Promise<string>), decrypt: (function(*, *): Promise<string>)}}
* @type {{encrypt: (function(*, *): Promise<string>), decrypt: (function(*, *): Promise<string>), decryptBytes: (function(*, *): Promise<Uint8Array>)}}
*/
window.cryptoInterop = {
encrypt: async function (plaintext, base64Key) {
@@ -59,6 +59,30 @@ window.cryptoInterop = {
const decoder = new TextDecoder();
return decoder.decode(decrypted);
},
decryptBytes: async function (base64Ciphertext, base64Key) {
const key = await window.crypto.subtle.importKey(
"raw",
Uint8Array.from(atob(base64Key), c => c.charCodeAt(0)),
{
name: "AES-GCM",
length: 256,
},
false,
["decrypt"]
);
const ivAndCiphertext = Uint8Array.from(atob(base64Ciphertext), c => c.charCodeAt(0));
const iv = ivAndCiphertext.slice(0, 12);
const ciphertext = ivAndCiphertext.slice(12);
const decrypted = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv },
key,
ciphertext
);
return new Uint8Array(decrypted);
}
};

View File

@@ -44,13 +44,13 @@ public class UserEmailClaim
public string Address { get; set; } = null!;
/// <summary>
/// Gets or sets the email adress local part.
/// Gets or sets the email address local part.
/// </summary>
[StringLength(255)]
public string AddressLocal { get; set; } = null!;
/// <summary>
/// Gets or sets the email adress domain part.
/// Gets or sets the email address domain part.
/// </summary>
[StringLength(255)]
public string AddressDomain { get; set; } = null!;

View File

@@ -151,3 +151,109 @@ Juliana
Charlie
Lucia
Stella
Adriana
Beatrice
Bianca
Calliope
Carmen
Celeste
Dakota
Diana
Esther
Florence
Francesca
Georgia
Harlow
Haven
Holly
Hope
India
Indie
Iris
Juniper
Kaia
Keira
Lara
Laura
Laurel
Luna
Magnolia
Maeve
Marina
Marlowe
Nina
Noelle
Octavia
Olive
Ophelia
Phoenix
Poppy
Primrose
Ramona
River
Rosalie
Rosemary
Sage
Salem
Selena
Sienna
Summer
Sylvie
Thea
Tessa
Wren
Winter
Willa
Ada
Aspen
Blair
Brynn
Cassidy
Cecilia
Daisy
Dawn
Daphne
Ember
Fiona
Flora
Freya
Gemma
Giselle
Harmony
Heidi
Imogen
Indie
Jessie
June
Kaia
Lena
Lola
Mabel
Maisie
Margot
Matilda
Mira
Morgan
Nell
Nadia
Odette
Opal
Pearl
Phoebe
Raven
Reese
Robin
Rowan
Ruth
Sabrina
Sasha
Sierra
Skye
Sloane
Talia
Thora
Vera
Willa
Winnie
Yara
Zara

View File

@@ -140,3 +140,108 @@ Levi
Alan
Jorge
Carson
Felix
Oliver
Theodore
Harrison
Maxwell
Sebastian
Xavier
Dominick
Lincoln
Elliott
Walter
Simon
Dean
Hugo
Malcolm
Leon
Oscar
Calvin
Raymond
Edgar
Franklin
Arthur
Lawrence
Dennis
Russell
Douglas
Leonard
Gregory
Harold
Frederick
Martin
Curtis
Stanley
Gilbert
Harvey
Francis
Eugene
Ralph
Roy
Albert
Bruce
Ronald
Keith
Craig
Roger
Randy
Gary
Dennis
Edwin
Don
Glen
Gordon
Howard
Earl
Leo
Lloyd
Milton
Norman
Roland
Vernon
Warren
Alfred
Bernard
Chester
Clarence
Clifford
Clyde
Dale
Dan
Darrell
Floyd
Herman
Jerome
Maurice
Neil
Ray
Rodney
Roland
Stuart
Wallace
Wayne
Wendell
Barry
Cecil
Claude
Daryl
Edmund
Everett
Ferdinand
Forrest
Gerald
Hugh
Irving
Leslie
Marvin
Morris
Nelson
Perry
Phillip
Roderick
Ross
Terrence
Wade
Winston
Zachariah

View File

@@ -165,3 +165,107 @@ Shaw
Snyder
Mason
Dixon
Blackwood
Shepherd
Frost
Hawkins
Pearson
Fleming
Dawson
Palmer
Nash
Barker
Thornton
Fitzgerald
Winters
Mckenzie
Chandler
Griffith
Cunningham
Doyle
Fletcher
Hicks
Walton
Briggs
Pearce
Nichols
Blake
Hodges
Benson
Marsh
Whitaker
Skinner
Robbins
Goodwin
Kirby
Savage
Hensley
Hancock
Pratt
Gallagher
Yates
Dennis
Swanson
Steele
Bauer
Holt
Barber
Schultz
Foley
Fowler
Wise
Malone
Cannon
Tate
Stark
Welch
Dyer
Booth
Payne
Shannon
Harmon
Woodward
Morse
Jacobson
Knowles
Blanchard
Dillon
Stokes
Buckley
Dickerson
Middleton
Sellers
Cobb
Stephenson
Roach
Moody
Beard
Mccarthy
Garner
Mcguire
Sloan
Ballard
Shields
Orr
Savage
Graves
Dempsey
Weeks
Mckay
Cooke
Riddle
Gates
Atkins
Farrell
Lowery
Huffman
Livingston
Davenport
Hendricks
Kerr
Pollard
Hoover
Wolfe
Bowman
Underwood
Frazier

View File

@@ -102,3 +102,110 @@ Juul
Lise
Myrthe
Veerle
Aafke
Alicia
Amira
Aniek
Annabel
Annelies
Anouk
Astrid
Babette
Bianca
Britt
Carlijn
Chantal
Claire
Dagmar
Danique
Daphne
Denise
Dominique
Doris
Eefje
Elena
Eline
Elisa
Elisabeth
Ellen
Esther
Eveline
Fabienne
Felice
Fleur
Frederique
Gwen
Hanna
Heleen
Helena
Ilona
Imke
Inge
Irene
Iris
Janna
Janneke
Jasmine
Jennifer
Jessica
Joelle
Judith
Julia
Karin
Karlijn
Kim
Kirsten
Kyra
Laura
Lena
Lianne
Liesbeth
Linda
Lisanne
Lisette
Louise
Maartje
Manon
Margot
Marieke
Marijke
Marlies
Marloes
Marthe
Melissa
Michelle
Nadine
Natalie
Nicole
Nina
Noortje
Paulien
Petra
Rachel
Renee
Robin
Rosa
Roxanne
Sabine
Sandra
Saskia
Silke
Simone
Suzanne
Sylvie
Tamara
Tanja
Tara
Thea
Thirza
Tina
Tineke
Ursula
Victoria
Wendy
Wilma
Xandra
Yasmin
Yvette
Yvonne
Zara

View File

@@ -99,3 +99,114 @@ Mijs
Mika
Felix
Merlijn
Alexander
Aron
Arthur
Axel
Bas
Bastiaan
Berend
Björn
Casper
Cees
Chris
Christian
Christiaan
Colin
Cornelis
Dani
Dennis
Dirk
Dominic
Eduard
Eelco
Erik
Erwin
Ezra
Faas
Filip
Florian
Frank
Frederik
Freek
Gerard
Gerrit
Giel
Gijs
Glenn
Govert
Harm
Harold
Hendrik
Henrik
Huub
Ian
Ivo
Jacob
Jake
Jan
Jarno
Jason
Jeffrey
Jeremy
Jim
Jimmy
Johan
Johannes
Jonas
Jonathan
Jos
Joshua
Justin
Kay
Kevin
Kjeld
Klaas
Lennard
Lennart
Leon
Lex
Liam
Loek
Lorenzo
Louis
Lowie
Maarten
Magnus
Maikel
Marc
Marcel
Marco
Martijn
Mathias
Matthijs
Maurits
Menno
Michiel
Nathan
Nico
Oscar
Pascal
Patrick
Paul
Peter
Philip
Pieter
Pim
Quincy
Remco
Rick
Rik
Robert
Rogier
Rowan
Ruud
Simon
Stefan
Steven
Thom
Victor
Vincent
Willem
Wouter
Yannick

View File

@@ -104,3 +104,104 @@ van Asselt
Timmermans
van Vliet
van Rijn
van Schaik
Bosman
Wolters
van Hout
Hermans
van Rooij
de Vos
van Donselaar
Evers
van den Brink
Verkerk
Groeneveld
van Duijn
Schuurman
Hoogendoorn
van Zanten
Koopman
Cornelissen
van Driel
Teunissen
Versteeg
van Deursen
Schipper
van Kempen
Bouwman
van der Valk
Nijhuis
van der Werf
van den Akker
Verhoef
Wessels
van der Poel
Driessen
van Oosten
Lambrechts
van der Vlist
Hoogeveen
van Gils
Rietveld
Barendrecht
van der Spek
Stam
van der Linde
Boersma
van Dijk
Schepers
van der Kolk
Roelofs
van der Velden
van den Burg
Westra
van der Steen
Pronk
van der Veer
Rozendaal
van den Bos
Konings
van der Wiel
Noordam
van der Laan
Schut
van der Vlugt
Witteveen
van der Zwan
Boogaard
van der Waal
Stolk
van der Windt
Rutten
van der Zanden
Spaans
van der Zwaan
Roos
van der Zijl
Schoenmaker
van Diepen
Romeijn
van Doesburg
Schippers
van Eck
Rijken
van Egmond
Schrama
van Eijk
Ruijter
van Engelen
Sanders
van Es
Schenk
van Essen
van Gaal
van Geenen
van Gent
van Gestel
van Gool
van Grinsven
van Gurp
van Haaften
van Haren
van Hattem
van Hees

View File

@@ -67,25 +67,55 @@ public class UsernameEmailGenerator
{
var parts = new List<string>();
// Use first initial + last name
if (_random.Next(2) == 0)
switch (_random.Next(4))
{
parts.Add(identity.FirstName.Substring(0, 1).ToLower() + identity.LastName.ToLower());
}
else
{
// Use full name
parts.Add((identity.FirstName + identity.LastName).ToLower());
case 0:
// First initial + last name
parts.Add(identity.FirstName.Substring(0, 1).ToLower() + identity.LastName.ToLower());
break;
case 1:
// Full name
parts.Add((identity.FirstName + identity.LastName).ToLower());
break;
case 2:
// First name + last initial
parts.Add(identity.FirstName.ToLower() + identity.LastName.Substring(0, 1).ToLower());
break;
case 3:
// First 3 chars of first name + last name
parts.Add(identity.FirstName.Substring(0, Math.Min(3, identity.FirstName.Length)).ToLower() + identity.LastName.ToLower());
break;
}
// Add birth year
if (_random.Next(2) == 0)
// Add birth year variations
if (_random.Next(3) != 0)
{
parts.Add(identity.BirthDate.Year.ToString().Substring(2));
switch (_random.Next(2))
{
case 0:
parts.Add(identity.BirthDate.Year.ToString().Substring(2));
break;
case 1:
parts.Add(identity.BirthDate.Year.ToString());
break;
}
}
else if (_random.Next(2) == 0)
{
// Add random numbers for more uniqueness
parts.Add(_random.Next(10, 999).ToString());
}
// Join parts and sanitize
// Join parts with random symbols, possibly multiple
var emailPrefix = string.Join(GetRandomSymbol(), parts);
// Add extra random symbol at random position
if (_random.Next(2) == 0)
{
int position = _random.Next(emailPrefix.Length);
emailPrefix = emailPrefix.Insert(position, GetRandomSymbol());
}
emailPrefix = SanitizeEmailPrefix(emailPrefix);
// Adjust length

View File

@@ -1,18 +1,22 @@
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy the project files and restore dependencies
COPY ["src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj", "src/Services/AliasVault.SmtpService/"]
RUN dotnet restore "./src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj"
RUN dotnet restore "./src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj" -a "$TARGETARCH"
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" \
-a "$TARGETARCH" \
-o /app/publish \
/p:UseAppHost=false
FROM base AS final
WORKDIR /app

View File

@@ -334,7 +334,7 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
var insertedId = await InsertEmailIntoDatabase(message, new MailAddress(toAddress.AsAddress()), userPublicKey);
logger.LogInformation(
"Email for {ToAddress} successfully saved into database with ID {insertedId}.",
"Email for {ToAddress} successfully saved into database with ID {InsertedId}.",
toAddress.User + "@" + toAddress.Host,
insertedId);
return true;

View File

@@ -1,7 +1,13 @@
#!/bin/bash
generate_random_string() {
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-10} | head -n 1
LC_ALL=C tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w ${1:-10} | head -n 1
}
generate_random_attachment() {
local temp_file="/tmp/test_attachment_$(generate_random_string 8).txt"
echo "This is a test attachment content - $(generate_random_string 32)" > "$temp_file"
echo "$temp_file"
}
print_logo() {
@@ -23,25 +29,54 @@ print_logo() {
send_email() {
local recipient="$1"
local with_attachment="$2"
local subject_suffix=$(generate_random_string 8)
local content_suffix=$(generate_random_string 20)
local boundary="boundary-$(generate_random_string 16)"
local attachment_content="This is a test attachment content - $(generate_random_string 32)"
local attachment_name="test_attachment_$(generate_random_string 8).txt"
cat > temp_email.txt << EOF
From: sender@example.com
To: $recipient
Subject: Test Email - $subject_suffix
This is a test email.
Random content: $content_suffix
EOF
curl --url "smtp://localhost:25" \
--mail-from "sender@example.com" \
--mail-rcpt "$recipient" \
--upload-file temp_email.txt
rm temp_email.txt
if [[ "$with_attachment" =~ ^[Yy]$ ]]; then
{
echo "From: sender@example.com"
echo "To: $recipient"
echo "Subject: Test Email with Attachment - $subject_suffix"
echo "MIME-Version: 1.0"
echo "Content-Type: multipart/mixed; boundary=$boundary"
echo ""
echo "--$boundary"
echo "Content-Type: text/plain; charset=utf-8"
echo ""
echo "This is a test email with attachment."
echo ""
echo "Random content: $content_suffix"
echo ""
echo "--$boundary"
echo "Content-Type: application/octet-stream"
echo "Content-Transfer-Encoding: base64"
echo "Content-Disposition: attachment; filename=\"$attachment_name\""
echo ""
echo "$attachment_content" | base64
echo ""
echo "--$boundary--"
} | curl --url "smtp://localhost:25" \
--mail-from "sender@example.com" \
--mail-rcpt "$recipient" \
--upload-file -
else
{
echo "From: sender@example.com"
echo "To: $recipient"
echo "Subject: Test Email - $subject_suffix"
echo ""
echo "This is a test email."
echo ""
echo "Random content: $content_suffix"
} | curl --url "smtp://localhost:25" \
--mail-from "sender@example.com" \
--mail-rcpt "$recipient" \
--upload-file -
fi
}
print_logo
@@ -49,19 +84,18 @@ print_logo
while true; do
if [[ -z "$recipient" ]]; then
read -p "Enter the recipient's email address: " recipient
read -p "Do you want to send emails with attachments? (y/N): " with_attachment
fi
send_email "$recipient"
send_email "$recipient" "$with_attachment"
read -p "Send another email? (Press Enter for same recipient, or type a new email, or 'q' to quit): " next_action
read -p "Send another email? (Press Enter for same recipient/settings, or type a new email, or 'q' to quit): " next_action
if [[ "$next_action" == "q" ]]; then
echo "Exiting the script. Goodbye!"
exit 0
elif [[ -n "$next_action" ]]; then
recipient="$next_action"
else
# If next_action is empty (user pressed Enter), keep the same recipient
:
read -p "Do you want to send emails with attachments? (y/N): " with_attachment
fi
done

View File

@@ -1,20 +1,22 @@
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
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"
RUN dotnet restore "./src/Services/AliasVault.TaskRunner/AliasVault.TaskRunner.csproj" -a "$TARGETARCH"
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
RUN dotnet publish "./AliasVault.TaskRunner.csproj" \
-c "$BUILD_CONFIGURATION" \
-a "$TARGETARCH" \
-o /app/publish \
/p:UseAppHost=false
FROM base AS final
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "AliasVault.TaskRunner.dll"]

View File

@@ -101,23 +101,34 @@ public class TaskRunnerWorker(
{
foreach (var task in tasks)
{
// Check cancellation before each task
stoppingToken.ThrowIfCancellationRequested();
try
{
job.Status = TaskRunnerJobStatus.Running;
await dbContext.SaveChangesAsync(stoppingToken);
await task.ExecuteAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// Handle cancellation gracefully
job.Status = TaskRunnerJobStatus.Canceled;
job.ErrorMessage = "Task execution was canceled.";
await dbContext.SaveChangesAsync(stoppingToken);
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing task {TaskName}", task.Name);
job.ErrorMessage = $"Task {task.Name} failed: {ex.Message}";
job.Status = TaskRunnerJobStatus.Error;
job.ErrorMessage = $"Task {task.Name} failed: {ex.Message}";
await dbContext.SaveChangesAsync(stoppingToken);
break;
}
}
if (job.Status != TaskRunnerJobStatus.Error)
if (job.Status != TaskRunnerJobStatus.Error && job.Status != TaskRunnerJobStatus.Canceled)
{
job.Status = TaskRunnerJobStatus.Finished;
}

View File

@@ -25,7 +25,7 @@ public static class AppInfo
/// <summary>
/// Gets the minor version number.
/// </summary>
public const int VersionMinor = 10;
public const int VersionMinor = 11;
/// <summary>
/// Gets the patch version number.

View File

@@ -27,6 +27,11 @@ public enum TaskRunnerJobStatus
/// </summary>
Finished = 2,
/// <summary>
/// The job has been canceled because the task runner has been stopped.
/// </summary>
Canceled = 8,
/// <summary>
/// The job has failed.
/// </summary>

View File

@@ -18,4 +18,9 @@ public class MailboxEmailApiModel : EmailApiModelBase
/// Gets or sets the preview of the email message.
/// </summary>
public string MessagePreview { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the email has attachments.
/// </summary>
public bool HasAttachments { get; set; }
}

View File

@@ -32,12 +32,12 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.5.0">
<PackageReference Include="NUnit.Analyzers" Version="4.6.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.49.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -7,6 +7,7 @@
namespace AliasVault.E2ETests.Tests.Client.Shard1;
using System.Text;
using AliasVault.IntegrationTests.SmtpServer;
using MailKit.Net.Smtp;
using MailKit.Security;
@@ -46,7 +47,7 @@ public class EmailDecryptionTests : ClientPlaywrightTest
}
/// <summary>
/// Test if received email encrypted by server can be successfully decrypted by client
/// Test if received email without attachments encrypted by server can be successfully decrypted by client
/// and then be deleted by client.
/// </summary>
/// <returns>Async task.</returns>
@@ -56,7 +57,7 @@ public class EmailDecryptionTests : ClientPlaywrightTest
{
// Create credential which should automatically create claim on server during database sync.
const string serviceName = "Test Service";
const string email = "testclaim@example.tld";
const string email = "testclaim2@example.tld";
await CreateCredentialEntry(new Dictionary<string, string>
{
{ "service-name", serviceName },
@@ -72,7 +73,7 @@ public class EmailDecryptionTests : ClientPlaywrightTest
Assert.That(publicKey, Is.Not.Null, "Public key for user not found in database. Check if public key creation is working correctly.");
Assert.That(publicKey.PublicKey, Has.Length.GreaterThanOrEqualTo(100), "Public key exists but length does not match expected. Check if public key creation is working correctly.");
// Email the SMTP server which will save the email in encrypted form in the database..
// Email the SMTP server which will save the email in encrypted form in the database.
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", email));
@@ -93,6 +94,7 @@ public class EmailDecryptionTests : ClientPlaywrightTest
HtmlBody = htmlBody,
};
message.Body = bodyBuilder.ToMessageBody();
await SendMessageToSmtpServer(message);
// Assert that email was received by the server.
@@ -138,11 +140,149 @@ public class EmailDecryptionTests : ClientPlaywrightTest
}
/// <summary>
/// Test that adding a credential with email domain that is not in the known list to not get added as claim.
/// Test if received email including attachment encrypted by server can be successfully decrypted by client
/// and then be deleted by client.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(2)]
public async Task EmailEncryptionDecryptionAttachmentDeleteTest()
{
// Create credential which should automatically create claim on server during database sync.
const string serviceName = "Test Service";
const string email = "testclaim@example.tld";
await CreateCredentialEntry(new Dictionary<string, string>
{
{ "service-name", serviceName },
{ "email", email },
});
// Assert that the claim was created on the server.
var claim = await ApiDbContext.UserEmailClaims.Where(x => x.Address == email).FirstOrDefaultAsync();
Assert.That(claim, Is.Not.Null, "Claim for email address not found in database. Check if credential creation and claim creation are working correctly.");
// Assert that the users public key was created on the server.
var publicKey = await ApiDbContext.UserEncryptionKeys.Where(x => x.UserId == claim.UserId).FirstOrDefaultAsync();
Assert.That(publicKey, Is.Not.Null, "Public key for user not found in database. Check if public key creation is working correctly.");
Assert.That(publicKey!.PublicKey, Has.Length.GreaterThanOrEqualTo(100), "Public key exists but length does not match expected. Check if public key creation is working correctly.");
// Email the SMTP server which will save the email in encrypted form in the database..
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Test Sender", "sender@example.com"));
message.To.Add(new MailboxAddress("Test Recipient", email));
const string textSubject = "Encrypted Email Subject";
const string textBody = "This is a test email plain.";
const string htmlBody = @"
<html>
<body>
<h1>Test Email</h1>
<p>This is a test email with HTML content.</p>
<p>Sample anchor tag: <a href=""https://example.com"">Example Link</a></p>
</body>
</html>";
message.Subject = textSubject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody,
HtmlBody = htmlBody,
};
var attachment = new MimePart("text", "plain")
{
Content = new MimeContent(new MemoryStream(Encoding.UTF8.GetBytes("This is an attachment."))),
ContentDisposition = new ContentDisposition(ContentDisposition.Attachment),
ContentTransferEncoding = ContentEncoding.Base64,
FileName = "attachment.txt",
};
bodyBuilder.Attachments.Add(attachment);
message.Body = bodyBuilder.ToMessageBody();
await SendMessageToSmtpServer(message);
// Assert that email was received by the server.
var emailReceived = await ApiDbContext.Emails.FirstOrDefaultAsync(x => x.To == email);
Assert.That(emailReceived, Is.Not.Null, "Email not received by server. Check SMTP server and email encryption/decryption logic.");
// Assert that the attachment is stored in the database.
var attachmentReceived = await ApiDbContext.EmailAttachments.FirstOrDefaultAsync(x => x.EmailId == emailReceived.Id);
Assert.That(attachmentReceived, Is.Not.Null, "Attachment not found in database. Check email attachment encryption logic.");
// Assert that the attachment content is encrypted
var attachmentContent = Encoding.UTF8.GetString(attachmentReceived!.Bytes);
Assert.Multiple(() =>
{
Assert.That(attachmentContent, Does.Not.Contain("This is an attachment."), "Attachment content stored as plain text in database. Check attachment encryption logic.");
Assert.That(attachmentContent, Is.Not.Empty, "Attachment content is empty. Check attachment encryption logic.");
});
// Assert that subject is not stored as plain text in the database.
Assert.That(emailReceived!.Subject, Does.Not.Contain(textSubject), "Email subject stored as plain text in database. Check email encryption logic.");
// Attempt to click on email refresh button to get new emails.
await Page.Locator("id=recent-email-refresh").First.ClickAsync();
await WaitForUrlAsync("credentials/**", "Subject");
// Check if the email is visible on the page now.
var emailContent = await Page.TextContentAsync("body");
Assert.That(emailContent, Does.Contain(textSubject), "Email not (correctly) decrypted and displayed on the credential page. Check email decryption logic.");
// Navigate to the email index page and ensure that the decrypted email is also readable there.
await NavigateUsingBlazorRouter("emails");
await WaitForUrlAsync("emails", "Inbox");
// Check if the email is visible on the page now.
emailContent = await Page.TextContentAsync("body");
Assert.That(emailContent, Does.Contain(textSubject), "Email not (correctly) decrypted and displayed on the emails page. Check email decryption logic.");
// Assert that the attachment indicator is visible on the page.
var attachmentIndicator = await Page.Locator(".attachment-indicator").First.GetAttributeAsync("class");
Assert.That(attachmentIndicator, Is.Not.Null, "Attachment indicator not visible on email page. Check email attachment decryption logic.");
// Attempt to click on the email subject to open the modal.
await Page.Locator("text=" + textSubject).First.ClickAsync();
await WaitForUrlAsync("emails**", "Delete");
// Assert that the anchor tag in the email iframe has target="_blank" attribute.
var anchorTag = await Page.Locator("iframe").First.GetAttributeAsync("srcdoc");
Assert.That(anchorTag, Does.Contain("target=\"_blank\""), "Anchor tag in email iframe does not have target=\"_blank\" attribute. Check email decryption logic.");
// Assert that email attachment metadata is visible in the modal.
var body = await Page.TextContentAsync("body");
Assert.That(body, Does.Contain("attachment.txt"), "Attachment metadata not visible in email modal. Check email attachment parse logic.");
// Assert that clicking on the attachment link downloads it.
await Page.Locator(".attachment-link").First.ClickAsync();
var download = await Page.WaitForDownloadAsync();
// Get the path of the downloaded file
var downloadedFilePath = await download.PathAsync();
// Read the content of the downloaded file
var downloadedContent = await File.ReadAllBytesAsync(downloadedFilePath);
// Compare with the original attachment content
var originalContent = Encoding.UTF8.GetBytes("This is an attachment.");
Assert.That(downloadedContent, Is.EqualTo(originalContent), "Downloaded attachment content does not match the original content.");
// Clean up: delete the downloaded file
File.Delete(downloadedFilePath);
// Click the delete button to delete the email.
await Page.Locator("id=delete-email").First.ClickAsync();
// Wait for the email delete confirm message to show up.
await WaitForUrlAsync("emails**", "Email deleted successfully");
// Assert that the email is no longer visible on the page.
body = await Page.TextContentAsync("body");
Assert.That(body, Does.Not.Contain(textSubject), "Email not deleted from page after deletion. Check email deletion logic.");
}
/// <summary>
/// Test that adding a credential with email domain that is not in the known list to not get added as claim.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(3)]
public async Task EmailUnknownDomainNoClaimTest()
{
// Create credential which should automatically create claim on server during database sync.
@@ -165,7 +305,7 @@ public class EmailDecryptionTests : ClientPlaywrightTest
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(3)]
[Order(4)]
public async Task EmailDuplicateClaimTest()
{
// Create credential which should automatically create claim on server during database sync.

View File

@@ -0,0 +1,164 @@
// -----------------------------------------------------------------------
// <copyright file="AbstractTestHostBuilder.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;
using System.Reflection;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for services in order to run integration tests against them. This class
/// contains common logic such as creating a temporary database.
/// </summary>
public class AbstractTestHostBuilder : IAsyncDisposable
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext != null)
{
return _dbContext;
}
_dbContext = _dbContextFactory.CreateDbContext();
return _dbContext;
}
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public async Task<AliasServerDbContext> GetDbContextAsync()
{
return await _dbContextFactory.CreateDbContextAsync();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn =
new NpgsqlConnection(
"Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Creates a new test host builder with test database connection already configured.
/// </summary>
/// <returns>IHost.</returns>
protected IHostBuilder CreateBuilder()
{
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Create a connection to 'postgres' database to ensure the test database exists
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Create a connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddAliasVaultDatabaseConfiguration(configuration);
services.ConfigureLogging(configuration, Assembly.GetExecutingAssembly().GetName().Name!, "logs");
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
return builder;
}
}

View File

@@ -20,11 +20,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="coverlet.collector" Version="6.0.3"/>
<PackageReference Include="MailKit" Version="4.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="NUnit" Version="4.3.2"/>
<PackageReference Include="NUnit.Analyzers" Version="4.5.0"/>
<PackageReference Include="NUnit.Analyzers" Version="4.6.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>

View File

@@ -8,81 +8,20 @@
namespace AliasVault.IntegrationTests.SmtpServer;
using System.Data.Common;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.SmtpService;
using AliasVault.SmtpService.Handlers;
using AliasVault.SmtpService.Workers;
using global::SmtpServer;
using global::SmtpServer.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the SmtpServiceWorker in order to run integration tests against it.
/// </summary>
public class TestHostBuilder : IAsyncDisposable
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext != null)
{
return _dbContext;
}
_dbContext = _dbContextFactory.CreateDbContext();
return _dbContext;
}
/// <summary>
/// Builds the SmtpService test host.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Create a connection to 'postgres' database to ensure the test database exists
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Create a connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
return Build(dbConnection);
}
/// <summary>
/// Builds the SmtpService test host with a provided database connection.
/// </summary>
@@ -90,102 +29,81 @@ public class TestHostBuilder : IAsyncDisposable
/// <returns>IHost.</returns>
public IHost Build(DbConnection dbConnection)
{
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
// Get base builder with database connection already configured.
var builder = CreateBuilder();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton(new Config
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
// Override database connection with provided connection.
services.Remove(services.First(x => x.ServiceType == typeof(IConfiguration)));
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
});
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>
{
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
services.AddSingleton<IConfiguration>(configuration);
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false));
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
});
services.AddAliasVaultDatabaseConfiguration(configuration);
services.AddHostedService<SmtpServerWorker>();
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
ConfigureSmtpServices(services);
});
return builder.Build();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// Builds the SmtpService test host with a new database connection.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
/// <returns>IHost.</returns>
public IHost Build()
{
if (_dbContext != null)
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
ConfigureSmtpServices(services);
});
if (!string.IsNullOrEmpty(_tempDbName))
return builder.Build();
}
/// <summary>
/// Configures the SMTP services for the test host.
/// </summary>
/// <param name="services">The service collection to configure.</param>
private static void ConfigureSmtpServices(IServiceCollection services)
{
services.AddSingleton(new Config
{
// Create a connection to 'postgres' database to drop the test database
using var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
});
// First terminate existing connections
using (var cmd = conn.CreateCommand())
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false));
GC.SuppressFinalize(this);
}
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
});
services.AddHostedService<SmtpServerWorker>();
}
}

View File

@@ -0,0 +1,73 @@
//-----------------------------------------------------------------------
// <copyright file="StatusHostedServiceTests.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.StatusHostedService;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Integration tests for StatusHostedService wrapper.
/// </summary>
[TestFixture]
public class StatusHostedServiceTests
{
/// <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();
await _testHostBuilder.DisposeAsync();
}
/// <summary>
/// Tests that the StatusHostedService properly logs errors from the wrapped service.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task LogsExceptionFromWrappedService()
{
// Start the service which will trigger the TestExceptionWorker to throw an exception.
await _testHost.StartAsync();
// Give it a moment to process.
await Task.Delay(3000);
// Check the logs for the expected error.
await using var dbContext = _testHostBuilder.GetDbContext();
var errorLog = await dbContext.Logs
.OrderByDescending(l => l.TimeStamp)
.FirstOrDefaultAsync(l => l.Level == "Error" && l.Exception.Contains("Test exception"));
Assert.That(errorLog, Is.Not.Null, "Expected error log from TestExceptionWorker was not found");
Assert.That(errorLog.Message, Does.Contain("An error occurred in StatusHostedService"), "Error log does not contain expected message from StatusHostedService");
}
}

View File

@@ -0,0 +1,23 @@
//-----------------------------------------------------------------------
// <copyright file="TestExceptionWorker.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.StatusHostedService;
using Microsoft.Extensions.Hosting;
/// <summary>
/// A simple worker that throws an exception during task execution. This is used for testing purposes.
/// </summary>
public class TestExceptionWorker() : BackgroundService
{
/// <inheritdoc/>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), stoppingToken);
throw new Exception("Test exception");
}
}

View File

@@ -0,0 +1,42 @@
// -----------------------------------------------------------------------
// <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.StatusHostedService;
using System.Reflection;
using AliasServerDb;
using AliasVault.WorkerStatus.ServiceExtensions;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Builder class for creating a test host for the StatusHostedService wrapper in order to run integration tests
/// against it. This primarily tests basic functionality of the hosted service such as starting, stopping and error
/// handling.
///
/// The StatusHostedService is a wrapper around the HostedService class that provides additional functionality for
/// managing the status of the hosted service. This includes being able to start and stop the services from the
/// AliasVault admin panel.
/// </summary>
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// Builds the test host for the TestExceptionWorker.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
services.AddStatusHostedService<TestExceptionWorker, AliasServerDbContext>(Assembly.GetExecutingAssembly().GetName().Name!);
});
return builder.Build();
}
}

View File

@@ -77,7 +77,7 @@ public class TaskRunnerTests
// Assert
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
var generalLogs = await dbContext.Logs.ToListAsync();
var generalLogs = await dbContext.Logs.Where(x => x.Application == "TestApp").ToListAsync();
Assert.That(generalLogs, Has.Count.EqualTo(50), "Only recent general logs should remain");
}

View File

@@ -7,149 +7,41 @@
namespace AliasVault.IntegrationTests.TaskRunner;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.Shared.Server.Services;
using AliasVault.TaskRunner.Tasks;
using AliasVault.TaskRunner.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the TaskRunner in order to run integration tests against it.
/// </summary>
public class TestHostBuilder : IAsyncDisposable
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public async Task<AliasServerDbContext> GetDbContextAsync()
{
return await _dbContextFactory.CreateDbContextAsync();
}
/// <summary>
/// Builds the TaskRunner test host.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Create a temporary database for the test
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Create a connection to 'postgres' database to create the test database
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Create the connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
services.AddAliasVaultDatabaseConfiguration(configuration);
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
// Ensure the database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
});
return builder.Build();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
}
GC.SuppressFinalize(this);
}
}

View File

@@ -27,18 +27,18 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.msbuild" Version="6.0.2">
<PackageReference Include="coverlet.msbuild" Version="6.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.5.0">
<PackageReference Include="NUnit.Analyzers" Version="4.6.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,21 +1,25 @@
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy csproj files and restore as distinct layers
COPY ["src/Utilities/AliasVault.InstallCli/AliasVault.InstallCli.csproj", "src/Utilities/AliasVault.InstallCli/"]
COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"]
RUN dotnet restore "src/Utilities/AliasVault.InstallCli/AliasVault.InstallCli.csproj"
RUN dotnet restore "src/Utilities/AliasVault.InstallCli/AliasVault.InstallCli.csproj" -a "$TARGETARCH"
# Copy the entire source code
COPY . .
# Build and publish in one step
RUN dotnet publish "src/Utilities/AliasVault.InstallCli/AliasVault.InstallCli.csproj" \
-c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
-c "$BUILD_CONFIGURATION" \
-a "$TARGETARCH" \
-o /app/publish \
/p:UseAppHost=false
FROM base AS final
WORKDIR /app

View File

@@ -147,7 +147,7 @@ public static partial class Program
await MigrateTable(sqliteContext.AliasVaultRoles, pgContext.AliasVaultRoles, pgContext, "AliasVaultRoles");
await MigrateTable(sqliteContext.AliasVaultUsers, pgContext.AliasVaultUsers, pgContext, "AliasVaultUsers");
await MigrateTable(sqliteContext.ServerSettings, pgContext.ServerSettings, pgContext, "ServerSettings");
await MigrateTable(sqliteContext.TaskRunnerJobs, pgContext.TaskRunnerJobs, pgContext, "TaskRunnerJobs");
await MigrateTable(sqliteContext.TaskRunnerJobs, pgContext.TaskRunnerJobs, pgContext, "TaskRunnerJobs", true);
await MigrateTable(sqliteContext.DataProtectionKeys, pgContext.DataProtectionKeys, pgContext, "DataProtectionKeys", true);
await MigrateTable(sqliteContext.Logs, pgContext.Logs, pgContext, "Logs", true);
await MigrateTable(sqliteContext.AuthLogs, pgContext.AuthLogs, pgContext, "AuthLogs", true);

View File

@@ -27,7 +27,7 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
/// <summary>
/// Maximum delay before restarting the worker.
/// </summary>
private const int _restartMaxDelayInMs = 300000;
private const int _restartMaxDelayInMs = 3600000;
/// <summary>
/// Lock object to prevent multiple tasks from starting the worker at the same time.
@@ -53,31 +53,70 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
while (!stoppingToken.IsCancellationRequested)
{
// Add a second cancellationToken linked to the parent cancellation token.
// When the parent gets canceled this gets canceled as well. However, this one can also
// be canceled with a signal from the StatusWorker.
var workerCancellationTokenSource =
CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
// Start the inner while loop with the second cancellationToken.
await ExecuteInnerAsync(workerCancellationTokenSource);
if (!stoppingToken.IsCancellationRequested)
try
{
// If the parent service was not stopped, wait for a second before attempting to restart the worker.
await Task.Delay(1000, stoppingToken);
// Start the inner while loop with the second cancellationToken.
await ExecuteInnerAsync(stoppingToken);
}
catch (OperationCanceledException ex)
{
// Expected so we only log information.
logger.LogInformation(ex, "StatusHostedService<{ServiceType}> is stopping due to a cancellation request.", typeof(T).Name);
break;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred in StatusHostedService<{ServiceType}>", typeof(T).Name);
}
finally
{
if (!stoppingToken.IsCancellationRequested)
{
// If the parent service was not stopped, wait for a second before attempting to restart the worker.
await Task.Delay(1000, stoppingToken);
}
}
}
}
/// <summary>
/// Calls the ExecuteAsync method of the inner service.
/// </summary>
/// <param name="innerService">The inner service.</param>
/// <param name="cancellationToken">Cancellation token.</param>
private static async Task CallExecuteAsync(T innerService, CancellationToken cancellationToken)
{
if (innerService is BackgroundService backgroundService)
{
var executeMethod = backgroundService.GetType().GetMethod("ExecuteAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var executionTask = (Task)executeMethod!.Invoke(backgroundService, new object[] { cancellationToken })!;
// Wait for the ExecuteAsync method to complete or throw.
await executionTask;
}
else
{
// For non-BackgroundService implementations, start the service as normal and wait indefinitely
await innerService.StartAsync(cancellationToken);
// For non-BackgroundService implementations, just wait indefinitely
await Task.Delay(Timeout.Infinite, cancellationToken);
}
}
/// <summary>
/// Start the inner while loop which adds a second cancellationToken that is controlled by the StatusWorker.
/// </summary>
/// <param name="workerCancellationTokenSource">Cancellation token.</param>
private async Task ExecuteInnerAsync(CancellationTokenSource workerCancellationTokenSource)
/// <param name="cancellationToken">Cancellation token.</param>
private async Task ExecuteInnerAsync(CancellationToken cancellationToken)
{
Task? workerTask = null;
// Add a second cancellationToken linked to the parent cancellation token.
// When the parent gets canceled this gets canceled as well. However, this one can also
// be canceled with a signal from the StatusWorker.
using var workerCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
while (!workerCancellationTokenSource.IsCancellationRequested)
{
if (globalServiceStatus.CurrentStatus.ToStatusEnum() == Status.Started || globalServiceStatus.CurrentStatus.ToStatusEnum() == Status.Starting)
@@ -86,7 +125,6 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
{
if (workerTask == null)
{
globalServiceStatus.SetWorkerStatus(typeof(T).Name, true);
workerTask = Task.Run(() => WorkerLogic(workerCancellationTokenSource.Token), workerCancellationTokenSource.Token);
}
}
@@ -100,11 +138,15 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
else if (globalServiceStatus.CurrentStatus.ToStatusEnum() == Status.Stopped)
{
// Do nothing, the worker is stopped.
globalServiceStatus.SetWorkerStatus(typeof(T).Name, false);
}
// Wait for a second before checking the status again.
await Task.Delay(1000);
await Task.Delay(1000, cancellationToken);
}
// If we get here, cancel the worker task if it is still running.
await workerCancellationTokenSource.CancelAsync();
}
/// <summary>
@@ -120,43 +162,53 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
{
globalServiceStatus.SetWorkerStatus(typeof(T).Name, true);
await innerService.StartAsync(cancellationToken);
await Task.Delay(Timeout.Infinite, cancellationToken);
// If the inner service is a BackgroundService, listen for the results via reflection.
await CallExecuteAsync(innerService, cancellationToken);
}
catch (OperationCanceledException ex)
{
// Expected so we only log information.
logger.LogInformation(ex, "StatusHostedService<{ServiceType}> is stopping due to a cancellation request.", typeof(T).Name);
break;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred in StatusHostedService<{ServiceType}>", typeof(T).Name);
// If service is explicitly stopped, break out of the loop immediately.
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
finally
{
logger.LogWarning("StatusHostedService<{ServiceType}> stopped at: {Time}", typeof(T).Name, DateTimeOffset.Now);
globalServiceStatus.SetWorkerStatus(typeof(T).Name, false);
// Reset the delay when the service is explicitly stopped
if (cancellationToken.IsCancellationRequested)
{
_restartDelayInMs = _restartMinDelayInMs;
}
}
// If a fault occurred in the innerService but it was not canceled,
// wait for a second before attempting to auto-restart the worker.
while (!cancellationToken.IsCancellationRequested)
if (cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(_restartDelayInMs, cancellationToken);
break; // Exit the loop if delay is successful
}
catch (TaskCanceledException)
{
// If the delay is canceled, exit the loop
break;
}
return;
}
// Exponential backoff with a maximum delay
_restartDelayInMs = Math.Min(_restartDelayInMs * 2, _restartMaxDelayInMs);
try
{
// If an exception occurred, delay with exponential backoff with a maximum before retrying.
await Task.Delay(_restartDelayInMs, cancellationToken);
_restartDelayInMs = Math.Min(_restartDelayInMs * 2, _restartMaxDelayInMs);
}
catch (OperationCanceledException)
{
// Reset delay on cancellation
_restartDelayInMs = _restartMinDelayInMs;
return;
}
}
}
}

View File

@@ -13,7 +13,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
/// <summary>
/// StatusWorker class for monitoring and controlling the status of the worker services.
/// StatusWorker class for monitoring and controlling the status of individual worker services through a database.
/// </summary>
public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbContext> createDbContext, GlobalServiceStatus globalServiceStatus) : BackgroundService
{
@@ -33,27 +33,18 @@ public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbCont
try
{
var statusEntry = await GetServiceStatus();
switch (statusEntry.CurrentStatus.ToStatusEnum())
{
case Status.Started:
// Ensure that all workers are running, if not, revert to "Starting" CurrentStatus.
if (!globalServiceStatus.AreAllWorkersRunning())
{
await SetServiceStatus(statusEntry, Status.Starting.ToString());
logger.LogInformation(
"Status was set to Started but not all workers are running (yet). Reverting to Starting.");
}
await HandleStartedStatus(statusEntry);
break;
case Status.Starting:
await WaitForAllWorkersToStart(stoppingToken);
await SetServiceStatus(statusEntry, Status.Started.ToString());
logger.LogInformation("All workers started.");
await HandleStartingStatus(statusEntry);
break;
case Status.Stopping:
await WaitForAllWorkersToStop(stoppingToken);
await SetServiceStatus(statusEntry, Status.Stopped.ToString());
logger.LogInformation("All workers stopped.");
await HandleStoppingStatus(statusEntry);
break;
case Status.Stopped:
logger.LogInformation("Service is (soft) stopped.");
@@ -78,6 +69,56 @@ public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbCont
await SetServiceStatus(await GetServiceStatus(), "Stopped");
}
/// <summary>
/// Handles the Started status.
/// </summary>
/// <param name="statusEntry">The WorkerServiceStatus entry.</param>
/// <returns>Task.</returns>
private async Task HandleStartedStatus(WorkerServiceStatus statusEntry)
{
if (!globalServiceStatus.AreAllWorkersRunning())
{
await SetServiceStatus(statusEntry, Status.Starting.ToString());
logger.LogInformation("Status was set to Started but not all workers are running (yet). Reverting to Starting.");
}
}
/// <summary>
/// Handles the Starting status.
/// </summary>
/// <param name="statusEntry">The WorkerServiceStatus entry.</param>
/// <returns>Task.</returns>
private async Task HandleStartingStatus(WorkerServiceStatus statusEntry)
{
if (globalServiceStatus.AreAllWorkersRunning())
{
await SetServiceStatus(statusEntry, Status.Started.ToString());
logger.LogInformation("All workers started.");
}
else
{
logger.LogInformation("Waiting for all workers to start.");
}
}
/// <summary>
/// Handles the Stopping status.
/// </summary>
/// <param name="statusEntry">The WorkerServiceStatus entry.</param>
/// <returns>Task.</returns>
private async Task HandleStoppingStatus(WorkerServiceStatus statusEntry)
{
if (globalServiceStatus.AreAllWorkersStopped())
{
await SetServiceStatus(statusEntry, Status.Stopped.ToString());
logger.LogInformation("All workers stopped.");
}
else
{
logger.LogInformation("Waiting for all workers to stop.");
}
}
/// <summary>
/// Gets the current status record of the service from database.
/// </summary>
@@ -126,32 +167,6 @@ public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbCont
await _dbContext.SaveChangesAsync();
}
/// <summary>
/// Waits for all workers to start.
/// </summary>
/// <param name="stoppingToken">CancellationToken.</param>
private async Task WaitForAllWorkersToStart(CancellationToken stoppingToken)
{
while (!globalServiceStatus.AreAllWorkersRunning())
{
logger.LogInformation("Waiting for all workers to start...");
await Task.Delay(1000, stoppingToken);
}
}
/// <summary>
/// Waits for all workers to stop.
/// </summary>
/// <param name="stoppingToken">CancellationToken.</param>
private async Task WaitForAllWorkersToStop(CancellationToken stoppingToken)
{
while (!globalServiceStatus.AreAllWorkersStopped())
{
logger.LogInformation("Waiting for all workers to stop...");
await Task.Delay(1000, stoppingToken);
}
}
/// <summary>
/// Retrieves status record or creates an initial status record if it does not exist.
/// </summary>