mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-01-04 20:20:37 -05:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
581d1dac5a | ||
|
|
50b3872ae0 | ||
|
|
2ea2526858 | ||
|
|
2d9b6f38b0 | ||
|
|
a941ffa837 | ||
|
|
e2da05ac2c | ||
|
|
dd8108c974 | ||
|
|
206f8fc2b1 | ||
|
|
5a432e4ab5 | ||
|
|
83ba9222bd | ||
|
|
7e7a8b04ef | ||
|
|
a28b5012d6 | ||
|
|
85218a8fd1 | ||
|
|
590454b69e | ||
|
|
d81d48ee16 | ||
|
|
b72217eb04 | ||
|
|
8942795e76 | ||
|
|
08290e1fa5 | ||
|
|
7b45b44735 | ||
|
|
ae6913a8e0 | ||
|
|
7470ac9e16 | ||
|
|
521d10da19 | ||
|
|
98aee7bb35 | ||
|
|
d62f2c4450 | ||
|
|
95edcc3042 | ||
|
|
1bce686121 | ||
|
|
742417d405 | ||
|
|
2cfc8d528d | ||
|
|
7a4e1721c8 | ||
|
|
11d79c4874 | ||
|
|
7cd35b0a92 | ||
|
|
d0f62a26c0 | ||
|
|
01198502a3 | ||
|
|
229ad109a7 | ||
|
|
837b16d971 | ||
|
|
4010d1b93f | ||
|
|
f7ce60ae68 | ||
|
|
5e61bd5db2 | ||
|
|
a2e8a438de | ||
|
|
92904dcf55 | ||
|
|
e4f2ca630b | ||
|
|
ed80ad24c1 | ||
|
|
0c368ab84b | ||
|
|
dee2044ed6 | ||
|
|
f6f6072b3f | ||
|
|
4bfe72d750 | ||
|
|
330f59dc10 | ||
|
|
a20d981427 | ||
|
|
bd2274db75 | ||
|
|
6cfa6f4ef5 | ||
|
|
8a40d2b1b9 | ||
|
|
237958ba0f | ||
|
|
79db3a54c7 | ||
|
|
2029745f8b | ||
|
|
ea4d498502 | ||
|
|
05838f5dca | ||
|
|
79872163e2 | ||
|
|
35d0f77dd6 | ||
|
|
6660cd20bd | ||
|
|
e236ba454f | ||
|
|
6ec66e4d64 | ||
|
|
14898c0c83 | ||
|
|
d08bec9df7 | ||
|
|
9107dfa789 | ||
|
|
351f6f4d16 | ||
|
|
aca607e579 | ||
|
|
ed053422ba | ||
|
|
955b8638ce | ||
|
|
1d8883cc94 | ||
|
|
48281f92e6 | ||
|
|
f19db2c010 | ||
|
|
f0d397c8af | ||
|
|
fafa51d787 | ||
|
|
202151e4f1 | ||
|
|
c123edccd4 | ||
|
|
50cab3a2f3 | ||
|
|
0184e32e6d | ||
|
|
d73d4e90e0 | ||
|
|
06d38842f5 | ||
|
|
b0748316ff | ||
|
|
8f8b4af3c9 | ||
|
|
11bf183cbb |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a bug or unexpected behavior.
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for AliasVault
|
||||
title: '[Feature Request] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
21
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
21
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
## Description
|
||||
|
||||
Please include a summary of the changes and the related issue(s).
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature enhancement
|
||||
- [ ] Documentation update
|
||||
- [ ] Other (please describe):
|
||||
|
||||
## Related Issues
|
||||
|
||||
Link to any issues that this PR addresses:
|
||||
Fixes #[issue-number]
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code adheres to project standards and guidelines.
|
||||
- [ ] Documentation has been updated where applicable.
|
||||
|
||||
## Additional Information
|
||||
|
||||
Add any additional context, screenshots, or explanations here.
|
||||
25
.github/workflows/docker-compose-pull.yml
vendored
25
.github/workflows/docker-compose-pull.yml
vendored
@@ -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
|
||||
|
||||
|
||||
14
.github/workflows/publish-docker-images.yml
vendored
14
.github/workflows/publish-docker-images.yml
vendored
@@ -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 }}
|
||||
|
||||
23
.github/workflows/sonarcloud-code-analysis.yml
vendored
23
.github/workflows/sonarcloud-code-analysis.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -415,3 +415,6 @@ docs/.bundle
|
||||
# Database files
|
||||
database/postgres
|
||||
database/postgres-dev
|
||||
|
||||
# Temp files
|
||||
temp
|
||||
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@support.aliasvault.net.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
60
README.md
60
README.md
@@ -5,11 +5,11 @@
|
||||
<h1><img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="40" /> AliasVault</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://app.aliasvault.net">Live demo 🔥</a> • <a href="https://aliasvault.net?utm_source=gh-readme">Website 🌐</a> • <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation 📚</a> • <a href="#installation">Installation ⚙️</a>
|
||||
<a href="https://app.aliasvault.net">Try cloud version 🔥</a> • <a href="https://aliasvault.net?utm_source=gh-readme">Website 🌐</a> • <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation 📚</a> • <a href="#self-host">Self-host instructions ⚙️</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Open-source password and alias manager</strong>
|
||||
<strong>Open-source password and (email) alias manager</strong>
|
||||
</p>
|
||||
|
||||
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/AliasVault/releases)
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
AliasVault is an end-to-end encrypted password and alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. The core of AliasVault is built with C# ASP.NET Blazor WASM technology. AliasVault can be self-hosted on your own server with Docker.
|
||||
AliasVault is an end-to-end encrypted password and (email) alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. The core of AliasVault is built with C# ASP.NET Blazor WASM technology. AliasVault can be self-hosted on your own server with Docker.
|
||||
|
||||
### What makes AliasVault unique:
|
||||
- **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
|
||||
@@ -37,43 +37,41 @@ AliasVault is an end-to-end encrypted password and alias manager that protects y
|
||||
|
||||
> Note: AliasVault is currently in active development and some features may not yet have been (fully) implemented. If you run into any issues, please create an issue on GitHub.
|
||||
|
||||
## Live demo
|
||||
A live demo of the app is available at the official website at [app.aliasvault.net](https://app.aliasvault.net) (up-to-date with `main` branch). You can create a free account to try it out yourself.
|
||||
## Official Cloud Version
|
||||
The official cloud version of AliasVault is freely available at [app.aliasvault.net](https://app.aliasvault.net). This fully supported platform is always up to date with our latest release. Create an account to protect your privacy today.
|
||||
|
||||
<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">
|
||||
[<img width="700" alt="Screenshot of AliasVault" src="docs/assets/img/screenshot.png">](https://app.aliasvault.net)
|
||||
|
||||
## Installation
|
||||
## Self-host
|
||||
|
||||
To install AliasVault, the easiest method is to use the provided install script. This will download the pre-built Docker images and start the containers.
|
||||
To self-host and install AliasVault on your own server, the easiest method is to use the provided install script. This will download the pre-built Docker images and start the containers.
|
||||
|
||||
### 1. Install using install script
|
||||
### Install using install script
|
||||
|
||||
This method uses pre-built Docker images and works on minimal hardware specifications:
|
||||
|
||||
- Linux VM with root access (Ubuntu or RHEL based distros recommended)
|
||||
- Linux VM with root access (Ubuntu/AlmaLinux recommended) or Raspberry Pi
|
||||
- 1 vCPU
|
||||
- 1GB RAM
|
||||
- 16GB disk space
|
||||
- Docker installed
|
||||
|
||||
```bash
|
||||
# Download install script
|
||||
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/main/install.sh
|
||||
# Download install script from latest stable release
|
||||
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/0.11.1/install.sh
|
||||
|
||||
# Make install script executable and run it. This will create the .env file, pull the Docker images, and start the AliasVault containers.
|
||||
chmod +x install.sh
|
||||
./install.sh install
|
||||
```
|
||||
|
||||
### 2. Post-Installation
|
||||
|
||||
The install script will output the URL where the app is available. By default this is:
|
||||
- Client: https://localhost
|
||||
- Admin portal: https://localhost/admin
|
||||
|
||||
> Note: If you want to change the default AliasVault ports you can do so in the `.env` file.
|
||||
|
||||
## Detailed documentation
|
||||
## Documentation
|
||||
For more detailed information about the installation process and other topics, please see the official documentation website:
|
||||
- [Documentation website (docs.aliasvault.net) 📚](https://docs.aliasvault.net)
|
||||
|
||||
@@ -92,21 +90,41 @@ For detailed information about our encryption implementation and security archit
|
||||
- [SECURITY.md](SECURITY.md)
|
||||
- [Security Architecture Diagram](https://docs.aliasvault.net/architecture)
|
||||
|
||||
## Roadmap
|
||||
AliasVault is under active development with new features being added regularly. We believe in transparency and want to share our vision for the future of the platform. Here's what we've accomplished and what we're working on next:
|
||||
|
||||
- [x] Core password & alias management
|
||||
- [x] End-to-end encryption
|
||||
- [x] Built-in email server for aliases
|
||||
- [x] Single-command Docker-based installation
|
||||
- [ ] Add support for connecting custom user domains to cloud hosted version (https://github.com/lanedirt/AliasVault/issues/485)
|
||||
- [ ] Add and associate TOTP MFA tokens to credentials (https://github.com/lanedirt/AliasVault/issues/181)
|
||||
- [ ] Browser extensions Chrome + Firefox (https://github.com/lanedirt/AliasVault/issues/541)
|
||||
- [ ] Import passwords from existing password managers (https://github.com/lanedirt/AliasVault/issues/542)
|
||||
|
||||
### Future Plans
|
||||
- [ ] Mobile apps (iOS, Android)
|
||||
- [ ] Team / organization features (sharing passwords/aliases)
|
||||
- [ ] Disposable phone number service
|
||||
|
||||
Want to suggest a feature? Join our [Discord](https://discord.gg/DsaXMTEtpF) or create an issue on GitHub.
|
||||
|
||||
## Tech stack / credits
|
||||
The following technologies, frameworks and libraries are used in this project:
|
||||
|
||||
- [C#](https://docs.microsoft.com/en-us/dotnet/csharp/) - A simple, modern, object-oriented, and type-safe programming language.
|
||||
- [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) - An open-source framework for building modern, cloud-based, internet-connected applications.
|
||||
- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) - A lightweight, extensible, open-source and cross-platform version of the popular Entity Framework data access technology.
|
||||
- [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) - An open-source framework for building modern multi-platform web applications.
|
||||
- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) - Object-relational mapping framework for .NET.
|
||||
- [Blazor WASM](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript. It's a single-page app framework that runs in the browser via WebAssembly.
|
||||
- [Playwright](https://playwright.dev/) - A Node.js library to automate Chromium, Firefox and WebKit with a single API. Used for end-to-end testing.
|
||||
- [Docker](https://www.docker.com/) - A platform for building, sharing, and running containerized applications.
|
||||
- [SQLite](https://www.sqlite.org/index.html) - A C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine.
|
||||
- [PostgreSQL](https://www.postgresql.org/) - An open-source object-relational database system used as the database for the server.
|
||||
- [Docker](https://www.docker.com/) - Used for containerizing the server and client apps.
|
||||
- [SQLite](https://www.sqlite.org/index.html) - A C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine. Used as database engine for the encrypted user's vault.
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework for rapidly building custom designs.
|
||||
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library based on Tailwind CSS.
|
||||
- [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography) - A .NET library that implements Argon2id, a memory-hard password hashing algorithm.
|
||||
- [SRP.net](https://github.com/secure-remote-password/srp.net) - SRP6a Secure Remote Password protocol for secure password authentication.
|
||||
- [SRP.net](https://github.com/secure-remote-password/srp.net) - SRP6a Secure Remote Password protocol for secure password authentication without sending plaintext passwords over the network.
|
||||
- [Playwright](https://playwright.dev/) - A Node.js library to automate Chromium, Firefox and WebKit with a single API. Used for end-to-end testing.
|
||||
- [SmtpServer](https://github.com/cosullivan/SmtpServer) - A SMTP server library for .NET that is used for the virtual email address feature.
|
||||
- [MimeKit](https://github.com/jstedfast/MimeKit) - A .NET MIME creation and parser library used for the virtual email address feature.
|
||||
- [StyleCop.Analyzers](https://github.com/DotNetAnalyzers/StyleCopAnalyzers) - Static code analysis tool that enforces style and consistency rules for C# code.
|
||||
- [SonarQube Cloud](https://www.sonarqube.org/) - A platform for continuous code quality management.
|
||||
|
||||
@@ -37,7 +37,17 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
HOSTNAME=localhost
|
||||
```
|
||||
|
||||
4. **Generate and set JWT_KEY**
|
||||
4. **Set default ports**
|
||||
|
||||
Update the .env file with the ports you want to use for the AliasVault components. The values defined here are used by the docker-compose.yml file.
|
||||
```bash
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
SMTP_PORT=25
|
||||
SMTP_TLS_PORT=587
|
||||
```
|
||||
|
||||
5. **Generate and set JWT_KEY**
|
||||
|
||||
Generate a random 32-char string for JWT token generation:
|
||||
```bash
|
||||
@@ -49,7 +59,7 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
JWT_KEY=your_generated_key_here
|
||||
```
|
||||
|
||||
5. **Generate and set DATA_PROTECTION_CERT_PASS**
|
||||
6. **Generate and set DATA_PROTECTION_CERT_PASS**
|
||||
|
||||
Generate a random password for the data protection certificate:
|
||||
```bash
|
||||
@@ -61,7 +71,7 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
DATA_PROTECTION_CERT_PASS=your_generated_password_here
|
||||
```
|
||||
|
||||
6. **Configure PostgreSQL Settings**
|
||||
7. **Configure PostgreSQL Settings**
|
||||
|
||||
Set the following PostgreSQL-related variables in your .env file:
|
||||
```bash
|
||||
@@ -75,7 +85,7 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
7. **Set PRIVATE_EMAIL_DOMAINS**
|
||||
8. **Set PRIVATE_EMAIL_DOMAINS**
|
||||
|
||||
Update the .env file with allowed email domains. Use DISABLED.TLD to disable email support:
|
||||
```bash
|
||||
@@ -86,14 +96,14 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
PRIVATE_EMAIL_DOMAINS=DISABLED.TLD
|
||||
```
|
||||
|
||||
8. **Set SUPPORT_EMAIL (Optional)**
|
||||
9. **Set SUPPORT_EMAIL (Optional)**
|
||||
|
||||
Add a support email address if desired:
|
||||
```bash
|
||||
SUPPORT_EMAIL=support@yourdomain.com
|
||||
```
|
||||
|
||||
9. **Generate admin password**
|
||||
10. **Generate admin password**
|
||||
|
||||
Build the Docker image for password hashing:
|
||||
```bash
|
||||
@@ -111,19 +121,25 @@ If you prefer to manually set up AliasVault, this README provides step-by-step i
|
||||
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
|
||||
```
|
||||
|
||||
10. **Build and start Docker containers**
|
||||
11. **Optional configuration**
|
||||
Enable or disable public registration of new users:
|
||||
```bash
|
||||
PUBLIC_REGISTRATION_ENABLED=false
|
||||
```
|
||||
|
||||
12. **Build and start Docker containers**
|
||||
|
||||
Build the Docker Compose stack:
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml build
|
||||
```
|
||||
|
||||
Start the Docker Compose stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
11. **Access AliasVault**
|
||||
13. **Access AliasVault**
|
||||
|
||||
AliasVault should now be running. You can access it at:
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Follow the steps in the checklist below to prepare a new release.
|
||||
## Versioning
|
||||
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs and update major/minor/patch to the new version. This version will be shown in the client and admin app footer.
|
||||
- [ ] Update ./install.sh `@version` in header if the install script has changed. This allows the install script to self-update when running the `./install.sh update` command on default installations.
|
||||
- [ ] Update README.md install.sh download link to point to the new release version
|
||||
|
||||
## Docker Images
|
||||
If docker containers have been added or removed:
|
||||
|
||||
247
install.sh
247
install.sh
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# @version 0.10.0
|
||||
# @version 0.11.1
|
||||
|
||||
# Repository information used for downloading files and images from GitHub
|
||||
REPO_OWNER="lanedirt"
|
||||
@@ -41,6 +41,7 @@ show_usage() {
|
||||
printf " uninstall Uninstall AliasVault\n"
|
||||
printf " update Update AliasVault to the latest version\n"
|
||||
printf " update-installer Check and update install.sh script if newer version available\n"
|
||||
printf " configure-hostname Configure the hostname where AliasVault can be accessed from\n"
|
||||
printf " configure-ssl Configure SSL certificates (Let's Encrypt or self-signed)\n"
|
||||
printf " configure-email Configure email domains for receiving emails\n"
|
||||
printf " configure-registration Configure new account registration (enable or disable)\n"
|
||||
@@ -60,6 +61,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 +73,7 @@ parse_args() {
|
||||
VERBOSE=false
|
||||
FORCE_YES=false
|
||||
COMMAND_ARG=""
|
||||
DEV_DB=false
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
show_usage
|
||||
@@ -114,6 +117,10 @@ parse_args() {
|
||||
COMMAND="reset-password"
|
||||
shift
|
||||
;;
|
||||
configure-hostname|hostname)
|
||||
COMMAND="configure-hostname"
|
||||
shift
|
||||
;;
|
||||
configure-ssl|ssl)
|
||||
COMMAND="configure-ssl"
|
||||
shift
|
||||
@@ -189,6 +196,10 @@ parse_args() {
|
||||
FORCE_YES=true
|
||||
shift
|
||||
;;
|
||||
--dev)
|
||||
DEV_DB=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
show_usage
|
||||
@@ -240,6 +251,9 @@ main() {
|
||||
"configure-registration")
|
||||
handle_registration_configuration
|
||||
;;
|
||||
"configure-hostname")
|
||||
handle_hostname_configuration
|
||||
;;
|
||||
"start")
|
||||
handle_start
|
||||
;;
|
||||
@@ -400,13 +414,17 @@ create_env_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Environment setup functions
|
||||
populate_hostname() {
|
||||
printf "${CYAN}> Checking HOSTNAME...${NC}\n"
|
||||
if ! grep -q "^HOSTNAME=" "$ENV_FILE" || [ -z "$(grep "^HOSTNAME=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
DEFAULT_HOSTNAME="localhost"
|
||||
read -p "Enter the hostname where AliasVault will be hosted (press Enter for default: $DEFAULT_HOSTNAME): " USER_HOSTNAME
|
||||
HOSTNAME=${USER_HOSTNAME:-$DEFAULT_HOSTNAME}
|
||||
while true; do
|
||||
read -p "Enter the (public) hostname where this AliasVault instance can be accessed from (e.g. aliasvault.net): " USER_HOSTNAME
|
||||
if [ -n "$USER_HOSTNAME" ]; then
|
||||
HOSTNAME="$USER_HOSTNAME"
|
||||
break
|
||||
else
|
||||
printf "${YELLOW}> Hostname cannot be empty. Please enter a valid hostname.${NC}\n"
|
||||
fi
|
||||
done
|
||||
update_env_var "HOSTNAME" "$HOSTNAME"
|
||||
else
|
||||
HOSTNAME=$(grep "^HOSTNAME=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
@@ -414,6 +432,7 @@ populate_hostname() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Environment setup functions
|
||||
populate_jwt_key() {
|
||||
printf "${CYAN}> Checking JWT_KEY...${NC}\n"
|
||||
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
@@ -484,7 +503,7 @@ set_smtp_tls_enabled() {
|
||||
set_support_email() {
|
||||
printf "${CYAN}> Checking SUPPORT_EMAIL...${NC}\n"
|
||||
if ! grep -q "^SUPPORT_EMAIL=" "$ENV_FILE"; then
|
||||
read -p "Enter support email address (optional, press Enter to skip): " SUPPORT_EMAIL
|
||||
read -p "Enter server admin support email address that is shown on contact page (optional, press Enter to skip): " SUPPORT_EMAIL
|
||||
update_env_var "SUPPORT_EMAIL" "$SUPPORT_EMAIL"
|
||||
else
|
||||
printf " ${GREEN}> SUPPORT_EMAIL already exists.${NC}\n"
|
||||
@@ -505,8 +524,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 +546,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
|
||||
@@ -611,13 +626,13 @@ print_success_message() {
|
||||
printf "${CYAN}To configure the server, login to the admin panel:${NC}\n"
|
||||
printf "\n"
|
||||
if [ -n "$PASSWORD" ]; then
|
||||
printf "Admin Panel: https://${HOSTNAME}/admin\n"
|
||||
printf "Admin Panel: https://localhost/admin\n"
|
||||
printf "Username: admin\n"
|
||||
printf "Password: $PASSWORD\n"
|
||||
printf "\n"
|
||||
printf "${YELLOW}(!) Caution: Make sure to backup the above credentials in a safe place, they won't be shown again!${NC}\n"
|
||||
else
|
||||
printf "Admin Panel: https://${HOSTNAME}/admin\n"
|
||||
printf "Admin Panel: https://localhost/admin\n"
|
||||
printf "Username: admin\n"
|
||||
printf "Password: (Previously set. Use ./install.sh reset-password to generate new one.)\n"
|
||||
fi
|
||||
@@ -626,7 +641,7 @@ print_success_message() {
|
||||
printf "\n"
|
||||
printf "${CYAN}In order to start using AliasVault, log into the client website:${NC}\n"
|
||||
printf "\n"
|
||||
printf "Client Website: https://${HOSTNAME}/\n"
|
||||
printf "Client Website: https://localhost/\n"
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
}
|
||||
@@ -806,7 +821,6 @@ handle_build() {
|
||||
|
||||
# Initialize environment with proper error handling
|
||||
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
|
||||
populate_hostname || { printf "${RED}> Failed to set hostname${NC}\n"; exit 1; }
|
||||
set_support_email || { printf "${RED}> Failed to set support email${NC}\n"; exit 1; }
|
||||
populate_jwt_key || { printf "${RED}> Failed to set JWT key${NC}\n"; exit 1; }
|
||||
populate_data_protection_cert_pass || { printf "${RED}> Failed to set certificate password${NC}\n"; exit 1; }
|
||||
@@ -945,6 +959,8 @@ handle_ssl_configuration() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
populate_hostname || { printf "${RED}> Failed to set hostname${NC}\n"; exit 1; }
|
||||
|
||||
# Get the current hostname and SSL config from .env
|
||||
CURRENT_HOSTNAME=$(grep "^HOSTNAME=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
LETSENCRYPT_ENABLED=$(grep "^LETSENCRYPT_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
@@ -968,7 +984,7 @@ handle_ssl_configuration() {
|
||||
printf "Currently using: ${YELLOW}Self-signed certificates${NC}\n"
|
||||
fi
|
||||
|
||||
printf "Current hostname: ${CYAN}${CURRENT_HOSTNAME}${NC}\n"
|
||||
printf "Current hostname: ${CYAN}${CURRENT_HOSTNAME}${NC} (To change this, run: ./install.sh configure-hostname)\n"
|
||||
printf "\n"
|
||||
printf "SSL Options:\n"
|
||||
printf "1) Activate and/or request new Let's Encrypt certificate (recommended for production)\n"
|
||||
@@ -1524,7 +1540,6 @@ handle_install_version() {
|
||||
|
||||
# Initialize environment
|
||||
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
|
||||
populate_hostname || { printf "${RED}> Failed to set hostname${NC}\n"; exit 1; }
|
||||
set_support_email || { printf "${RED}> Failed to set support email${NC}\n"; exit 1; }
|
||||
populate_jwt_key || { printf "${RED}> Failed to set JWT key${NC}\n"; exit 1; }
|
||||
populate_data_protection_cert_pass || { printf "${RED}> Failed to set certificate password${NC}\n"; exit 1; }
|
||||
@@ -1749,7 +1764,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 +1825,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 +1880,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 +1929,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 +1955,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 +1986,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"
|
||||
@@ -1933,4 +2000,38 @@ handle_db_import() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to handle hostname configuration
|
||||
handle_hostname_configuration() {
|
||||
printf "${YELLOW}+++ Hostname Configuration +++${NC}\n"
|
||||
printf "\n"
|
||||
|
||||
# Check if AliasVault is installed
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
printf "${RED}Error: AliasVault must be installed first.${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get current hostname
|
||||
CURRENT_HOSTNAME=$(grep "^HOSTNAME=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
printf "${CYAN}Removing current hostname ${CURRENT_HOSTNAME}${NC}...\n"
|
||||
printf "\n"
|
||||
|
||||
# Force hostname to be empty so populate_hostname will ask for a new one
|
||||
sed -i.bak "/^HOSTNAME=/d" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
|
||||
|
||||
# Reuse existing hostname population logic
|
||||
populate_hostname
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
printf "New hostname: ${CYAN}${HOSTNAME}${NC}\n"
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
else
|
||||
printf "${RED}> Failed to update hostname. Please try again.${NC}\n"
|
||||
printf "\n"
|
||||
printf "${MAGENTA}=========================================================${NC}\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<header>
|
||||
<nav class="fixed z-30 w-full border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4 bg-primary-100">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto relative">
|
||||
<div class="flex justify-start items-center">
|
||||
<a href="@NavigationService.BaseUri" class="flex mr-14 flex-shrink-0">
|
||||
<img src="/img/logo.svg" class="mr-3 h-8" alt="AliasVault Logo">
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
@if (isMenuOpen)
|
||||
{
|
||||
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
|
||||
<div class="absolute top-[38px] right-0 z-50 my-4 w-56 text-base list-none bg-white rounded-b-lg divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
|
||||
<div class="py-3 px-4">
|
||||
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@_username</span>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -602,6 +602,10 @@ video {
|
||||
top: 2.5rem;
|
||||
}
|
||||
|
||||
.top-\[38px\] {
|
||||
top: 38px;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -670,6 +674,10 @@ video {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.mr-14 {
|
||||
margin-right: 3.5rem;
|
||||
}
|
||||
@@ -1087,6 +1095,11 @@ video {
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-b-lg {
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
@@ -1148,6 +1161,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 +1516,6 @@ video {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.leading-6 {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.3.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.3.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.1" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
src/AliasVault.Api/Controllers/IdentityController.cs
Normal file
58
src/AliasVault.Api/Controllers/IdentityController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 .
|
||||
|
||||
|
||||
24
src/AliasVault.Api/Helpers/EmailHelper.cs
Normal file
24
src/AliasVault.Api/Helpers/EmailHelper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & Time
|
||||
Date
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
<header>
|
||||
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto">
|
||||
<div class="flex flex-shrink-0 justify-start items-center">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto relative">
|
||||
<div class="flex flex-shrink-0 justify-start items-center relative">
|
||||
<a href="/" class="flex mr-0 sm:mr-4 lg:mr-8">
|
||||
<img src="/img/icon-nopadding.png" class="mr-3 h-8 w-10" alt="AliasVault Logo">
|
||||
<span class="self-center hidden sm:flex text-2xl font-semibold content-start align-top whitespace-nowrap dark:text-white">
|
||||
@@ -39,52 +39,54 @@
|
||||
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute w-full md:w-64 top-12 right-0 z-50 my-4 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600 @(IsMobileMenuOpen ? "block" : "hidden")" id="mobileMenuDropdown" data-popper-placement="bottom">
|
||||
<ul class="lg:hidden py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="mobileMenuDropdownButton">
|
||||
<li>
|
||||
<NavLink href="/credentials" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.Prefix">
|
||||
Credentials
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/emails" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Emails
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="py-3 px-4">
|
||||
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
|
||||
|
||||
<div class="absolute w-full md:w-64 top-[40px] md:top-[39px] right-0 z-50 my-4 text-base list-none bg-white rounded-b-lg divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600 @(IsMobileMenuOpen ? "block" : "hidden")" id="mobileMenuDropdown" data-popper-placement="bottom">
|
||||
<ul class="lg:hidden py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="mobileMenuDropdownButton">
|
||||
<li>
|
||||
<NavLink href="/credentials" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.Prefix">
|
||||
Credentials
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/emails" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Emails
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="py-3 px-4">
|
||||
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
|
||||
</div>
|
||||
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="mobileMenuDropdownButton">
|
||||
<li>
|
||||
<NavLink href="/settings/general" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
General settings
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/settings/security" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Security settings
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Vault settings
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="w-full text-start py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">
|
||||
Toggle dark mode
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Log out
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="mobileMenuDropdownButton">
|
||||
<li>
|
||||
<NavLink href="/settings/general" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
General settings
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/settings/security" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Security settings
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Vault settings
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="w-full text-start py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">
|
||||
Toggle dark mode
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Log out
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ builder.Configuration.AddJsonFile($"appsettings.{builder.HostEnvironment.Environ
|
||||
|
||||
var config = new Config();
|
||||
builder.Configuration.Bind(config);
|
||||
if (string.IsNullOrEmpty(config.ApiUrl))
|
||||
{
|
||||
throw new KeyNotFoundException("ApiUrl is not set in the configuration.");
|
||||
}
|
||||
|
||||
if (config.PrivateEmailDomains == null || config.PrivateEmailDomains.Count == 0)
|
||||
{
|
||||
@@ -56,8 +52,9 @@ builder.Services.AddScoped(sp =>
|
||||
var httpClient = httpClientFactory.CreateClient("AliasVault.Api");
|
||||
var apiConfig = sp.GetRequiredService<Config>();
|
||||
|
||||
// Ensure the API URL ends with a forward slash
|
||||
var baseUrl = apiConfig.ApiUrl.TrimEnd('/') + "/";
|
||||
// If API URL is not set, use the current base URL and append "/api" which is the default for the Docker setup.
|
||||
// If API URL override is set (used e.g. in dev), then ensure the API URL ends with a forward slash.
|
||||
var baseUrl = string.IsNullOrEmpty(apiConfig.ApiUrl) ? builder.HostEnvironment.BaseAddress + "api/" : apiConfig.ApiUrl.TrimEnd('/') + "/";
|
||||
httpClient.BaseAddress = new Uri(baseUrl);
|
||||
return httpClient;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Set the default hostname for localhost debugging
|
||||
DEFAULT_HOSTNAME="localhost"
|
||||
# Set the default values
|
||||
DEFAULT_PRIVATE_EMAIL_DOMAINS="localmail.tld"
|
||||
DEFAULT_SUPPORT_EMAIL=""
|
||||
|
||||
# Use the provided HOSTNAME environment variable if it exists, otherwise use the default
|
||||
HOSTNAME=${HOSTNAME:-$DEFAULT_HOSTNAME}
|
||||
# Use the provided environment variables if they exist, otherwise use defaults
|
||||
PRIVATE_EMAIL_DOMAINS=${PRIVATE_EMAIL_DOMAINS:-$DEFAULT_PRIVATE_EMAIL_DOMAINS}
|
||||
SUPPORT_EMAIL=${SUPPORT_EMAIL:-$DEFAULT_SUPPORT_EMAIL}
|
||||
|
||||
@@ -25,8 +23,9 @@ if [ ! -f /etc/nginx/ssl/nginx.crt ] || [ ! -f /etc/nginx/ssl/nginx.key ]; then
|
||||
chmod 600 /etc/nginx/ssl/nginx.key
|
||||
fi
|
||||
|
||||
# Replace the default URL with the actual API URL constructed from hostname
|
||||
sed -i "s|http://localhost:5092|https://${HOSTNAME}/api|g" /usr/share/nginx/html/appsettings.json
|
||||
# Remove the default API URL as it's only used for local dev/debugging.
|
||||
# The app will use a relative URL instead (base url + "/api/" which is the default for the Docker setup).
|
||||
sed -i "s|\"ApiUrl\": \"http://localhost:5092\",||g" /usr/share/nginx/html/appsettings.json
|
||||
|
||||
# Convert comma-separated list to JSON array
|
||||
json_array=$(echo $PRIVATE_EMAIL_DOMAINS | awk '{split($0,a,","); printf "["; for(i=1;i<=length(a);i++) {printf "\"%s\"", a[i]; if(i<length(a)) printf ","} printf "]"}')
|
||||
|
||||
@@ -674,12 +674,36 @@ video {
|
||||
top: 5rem;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
.top-10 {
|
||||
top: 2.5rem;
|
||||
}
|
||||
|
||||
.z-20 {
|
||||
z-index: 20;
|
||||
.top-8 {
|
||||
top: 2rem;
|
||||
}
|
||||
|
||||
.top-9 {
|
||||
top: 2.25rem;
|
||||
}
|
||||
|
||||
.top-\[317px\] {
|
||||
top: 317px;
|
||||
}
|
||||
|
||||
.top-\[39px\] {
|
||||
top: 39px;
|
||||
}
|
||||
|
||||
.top-\[40px\] {
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
.-z-10 {
|
||||
z-index: -10;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.z-30 {
|
||||
@@ -694,10 +718,6 @@ video {
|
||||
grid-column: span 1 / span 1;
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.col-span-6 {
|
||||
grid-column: span 6 / span 6;
|
||||
}
|
||||
@@ -837,6 +857,14 @@ video {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.mt-\[318px\] {
|
||||
margin-top: 318px;
|
||||
}
|
||||
|
||||
.mt-\[317px\] {
|
||||
margin-top: 317px;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -936,6 +964,10 @@ video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.max-h-\[90vh\] {
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1028,6 +1060,10 @@ video {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1088,10 +1124,6 @@ video {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1282,6 +1314,21 @@ video {
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-b {
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-b-xl {
|
||||
border-bottom-right-radius: 0.75rem;
|
||||
border-bottom-left-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.rounded-b-lg {
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
@@ -2280,6 +2327,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 +2352,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 +2691,6 @@ video {
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.sm\:-top-2 {
|
||||
top: -0.5rem;
|
||||
}
|
||||
@@ -2680,6 +2723,10 @@ video {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sm\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sm\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -2714,10 +2761,18 @@ video {
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:top-\[39px\] {
|
||||
top: 39px;
|
||||
}
|
||||
|
||||
.md\:col-span-1 {
|
||||
grid-column: span 1 / span 1;
|
||||
}
|
||||
|
||||
.md\:col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.md\:ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
@@ -2778,10 +2833,6 @@ video {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.lg\:col-auto {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.lg\:col-span-1 {
|
||||
grid-column: span 1 / span 1;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
||||
@@ -28,8 +28,8 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ 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.
|
||||
/// </summary>
|
||||
public const int VersionPatch = 0;
|
||||
public const int VersionPatch = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the build number, typically used in CI/CD pipelines.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
||||
<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.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -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.
|
||||
|
||||
164
src/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs
Normal file
164
src/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user