Compare commits

...

20 Commits
0.8.1 ... 0.8.2

Author SHA1 Message Date
Leendert de Borst
86d7ee3e9b Merge pull request #431 from lanedirt/430-prepare-082-hotfix-release
Update version to 0.8.2
2024-11-27 16:31:44 +01:00
Leendert de Borst
a39ed8c0a7 Update version to 0.8.2 (#430) 2024-11-27 16:31:28 +01:00
Leendert de Borst
e772e722b5 Merge pull request #429 from lanedirt/428-update-email-installation-documentation
Update email installation documentation
2024-11-27 13:22:11 +01:00
Leendert de Borst
b6bf431062 Only test favicon extraction for known stable website (#428) 2024-11-27 12:55:28 +01:00
Leendert de Borst
aa41cceff3 Update email installation documentation (#428) 2024-11-27 12:51:25 +01:00
Leendert de Borst
1baea180aa Merge pull request #427 from lanedirt/426-client-logs-out-unexpectedly-when-kept-open-in-background-tab 2024-11-26 20:32:12 +01:00
Leendert de Borst
0d8143c62e Fix refresh token expired check (#426) 2024-11-26 19:08:41 +01:00
Leendert de Borst
4ae84052e8 Refactor RecentEmails.razor (#426) 2024-11-26 19:03:03 +01:00
Leendert de Borst
c73c41ca06 Refactor RecentEmails component to only load emails when app is visible (#426) 2024-11-26 18:33:35 +01:00
Leendert de Borst
5b58418e57 Fix refresh token grace period check (#426) 2024-11-26 18:24:33 +01:00
Leendert de Borst
7c7f7549c5 Merge pull request #423 from lanedirt/422-add-email-server-documentation
Add email server documentation
2024-11-25 23:26:26 +01:00
Leendert de Borst
38203fd767 Merge pull request #425 from lanedirt/424-make-installsh-be-able-to-update-itself
Update install.sh with self-update support
2024-11-25 23:18:42 +01:00
Leendert de Borst
a7b8484a84 Set email server to disabled by default (#422) 2024-11-25 23:18:27 +01:00
Leendert de Borst
a091a94737 Update docker-compose-build.yml for better resilience (#422) 2024-11-25 23:07:43 +01:00
Leendert de Borst
2c299a82b8 Update install.sh with self-update support (#424) 2024-11-25 23:03:13 +01:00
Leendert de Borst
5ee710750e Merge pull request #421 from lanedirt/420-limit-max-username-length
Add max username length restriction of 40 chars
2024-11-25 22:49:58 +01:00
Leendert de Borst
ed5ea31ca8 Add email server docs (#422) 2024-11-25 22:49:38 +01:00
Leendert de Borst
ffdb427184 Add email server setup command (#422) 2024-11-25 22:49:24 +01:00
Leendert de Borst
4cef3efa1f Refactor all tests to use shorter username (#420) 2024-11-25 21:40:15 +01:00
Leendert de Borst
a5c8908c6b Add max username length restriction of 40 chars (#420) 2024-11-25 19:40:27 +01:00
20 changed files with 689 additions and 192 deletions

View File

@@ -77,8 +77,19 @@ jobs:
# Exit with error if any service failed
if [ "$failed" = true ]; then
# Get container logs
echo "Container Logs:"
docker compose logs
echo "Container Logs admin:"
docker compose logs admin
echo "Container Logs api:"
docker compose logs api
echo "Container Logs client:"
docker compose logs client
echo "Container Logs smtp:"
docker compose logs smtp
echo "Container Logs reverse-proxy:"
docker compose logs reverse-proxy
# Restart containers for next test in case of failure
docker compose restart
exit 1
fi
@@ -90,4 +101,4 @@ jobs:
echo "Expected: 'New admin password: <at least 8 base64 chars>'"
echo "Actual: $output"
exit 1
fi
fi

3
.gitignore vendored
View File

@@ -390,6 +390,9 @@ src/Tests/AliasVault.E2ETests/appsettings.Development.json
# .env is generated by install.sh and therefore should be ignored
.env
# install.sh backup files are generated by install.sh self-update and therefore should be ignored
install.sh.backup
# Draw.io diagram temp files
*.drawio.*

View File

@@ -12,7 +12,7 @@ permalink: /
Open-source password and identity manager with email alias generation and zero-knowledge architecture.
{: .fs-6 .fw-300 }
[Installation](./installation){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 }
[Installation](./installation/install){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 }
[View on GitHub](https://github.com/lanedirt/AliasVault){: .btn .fs-5 .mb-4 .mb-md-0 }
---

View File

@@ -1,7 +1,7 @@
---
layout: default
title: Build from Source
parent: Installation Guide
parent: Advanced
nav_order: 1
---

View File

@@ -0,0 +1,9 @@
---
layout: default
title: Advanced
parent: Installation Guide
nav_order: 2
---
# Advanced Installation
The following guides provide more advanced installation options for AliasVault. These options are not required for the basic installation, but may be useful for advanced users.

View File

@@ -1,7 +1,7 @@
---
layout: default
title: Manual Setup
parent: Installation Guide
parent: Advanced
nav_order: 2
---

View File

@@ -4,80 +4,5 @@ title: Installation Guide
nav_order: 2
---
# Installation
Follow the steps below to install AliasVault on your own server. Minimum experience with Docker and Linux is required.
{: .toc }
* TOC
{:toc}
---
## 1. Basic Installation
To get AliasVault up and running quickly, run the install script to pull pre-built Docker images. The install script will also configure the .env file and start the AliasVault containers. You can get up and running in less than 5 minutes.
### Hardware requirements
- Linux VM with root access (Ubuntu or RHEL based distros recommended)
- 1 vCPU
- 512MB RAM
- 16GB disk space
- Docker installed
### Installation steps
1. Download the install script to a directory of your choice. All AliasVault files and directories will be created in this directory.
```bash
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/main/install.sh
```
2. Make the install script executable.
```bash
chmod +x install.sh
```
3. Run the install script. This will create the .env file, pull the Docker images, and start the AliasVault containers. Follow the on-screen prompts to configure AliasVault.
```bash
./install.sh install
```
> **Note**: AliasVault binds to ports 80 and 443 by default. If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file for the `reverse-proxy` (nginx) container. Afterwards re-run the `./install.sh install` command to restart the containers with the new port settings.
3. After the script completes, you can access AliasVault at:
- Client: `https://localhost`
- Admin: `https://localhost/admin`
---
## 2. SSL configuration
The default installation will create a self-signed SSL certificate and configure Nginx to use it.
You can however also use Let's Encrypt to generate valid SSL certificates and configure Nginx to use it. In order to make this work you will need the following:
- A public IPv4 address assigned to your server
- Port 80 and 443 on your server must be open and accessible from the internet
- A registered domain name with an A record pointing to your server's public IP address (e.g. mydomain.com)
### Steps
1. Run the install script with the `configure-ssl` option
```bash
./install.sh configure-ssl
```
2. Follow the prompts to configure Let's Encrypt.
### Reverting to self-signed SSL
If at any point you would like to revert to the self-signed SSL certificate, run the install script again with the `configure-ssl` option
and then in the prompt choose option 2.
---
## 3. Troubleshooting
### Resetting the admin password
If you have lost your admin password, you can reset it by running the install script with the `reset-password` option. This will generate a new random password and update the .env file with it. After that it will restart the AliasVault containers to apply the changes.
```bash
./install.sh reset-password
```
### Verbose output
If you need more detailed output from the install script, you can run it with the `--verbose` option. This will print more information to the console.
```bash
./install.sh install --verbose
```
# Installation Guide
The following guide will walk you through the steps to install AliasVault on your own server. Minimum experience with Docker and Linux is required.

View File

@@ -0,0 +1,150 @@
---
layout: default
title: Basic Install
parent: Installation Guide
nav_order: 1
---
# Basic Install
The following guide will walk you through the steps to install AliasVault on your own server. Minimum experience with Docker and Linux is required.
{: .toc }
* TOC
{:toc}
---
## 1. Basic Installation
To get AliasVault up and running quickly, run the install script to pull pre-built Docker images. The install script will also configure the .env file and start the AliasVault containers. You can get up and running in less than 5 minutes.
### Hardware requirements
- Linux VM with root access (Ubuntu or RHEL based distros recommended)
- 1 vCPU
- 512MB RAM
- 16GB disk space
- Docker installed
### Installation steps
1. Download the install script to a directory of your choice. All AliasVault files and directories will be created in this directory.
```bash
curl -o install.sh https://raw.githubusercontent.com/lanedirt/AliasVault/main/install.sh
```
2. Make the install script executable.
```bash
chmod +x install.sh
```
3. Run the install script. This will create the .env file, pull the Docker images, and start the AliasVault containers. Follow the on-screen prompts to configure AliasVault.
```bash
./install.sh install
```
> **Note**: AliasVault binds to ports 80 and 443 by default. If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file for the `reverse-proxy` (nginx) container. Afterwards re-run the `./install.sh install` command to restart the containers with the new port settings.
3. After the script completes, you can access AliasVault at:
- Client: `https://localhost`
- Admin: `https://localhost/admin`
---
## 2. SSL configuration
The default installation will create a self-signed SSL certificate and configure Nginx to use it.
You can however also use Let's Encrypt to generate valid SSL certificates and configure Nginx to use it. In order to make this work you will need the following:
- A public IPv4 address assigned to your server
- Port 80 and 443 on your server must be open and accessible from the internet
- A registered domain name with an A record pointing to your server's public IP address (e.g. mydomain.com)
### Steps
1. Run the install script with the `configure-ssl` option
```bash
./install.sh configure-ssl
```
2. Follow the prompts to configure Let's Encrypt.
### Reverting to self-signed SSL
If at any point you would like to revert to the self-signed SSL certificate, run the install script again with the `configure-ssl` option
and then in the prompt choose option 2.
---
## 3. Email Server Setup
AliasVault includes a built-in email server that can handle multiple custom domains for your aliases.
To set up the email server, you need the following:
- Public IPv4 address
- Open ports (25 and 587) in server firewall for SMTP traffic
- Access to DNS record management for your domain
### a) DNS Configuration
Configure the following DNS records for your domain:
| Name | Type | Priority | Content | TTL |
|------|------|----------|---------------------------|-----|
| mail | A | | `<your-server-public-ip>` | 3600 |
| @ | MX | 10 | `mail.<your-domain>` | 3600 |
> Note: Replace `<your-server-public-ip>` and `<your-domain>` with your actual values.
### b) Port Configuration
The email server requires the following ports to be open:
- Port 25: Standard SMTP (unencrypted)
- Port 587: SMTP with STARTTLS (encrypted)
#### Verifying Port Access
You can test if the SMTP ports are correctly configured using telnet:
```bash
# Test standard SMTP port
telnet <your-server-public-ip> 25
# Test secure SMTP port
telnet <your-server-public-ip> 587
```
If successful, you'll see a connection establishment message. Press Ctrl+C to exit the telnet session.
### c) Setting Up Email Domains
1. Run the email configuration script:
```bash
./install.sh configure-email
````
2. Follow the interactive prompts to:
- Configure your domain(s)
- Restart required services
3. Once configured, you can:
- Create new aliases in the AliasVault client
- Use your custom domain(s) for email addresses
- Note: you can configure the default domain for new aliases in the AliasVault client in Menu > Settings > Email Settings > Default Email Domain
- Start receiving emails on your aliases
{: .note }
Important: DNS propagation can take up to 24-48 hours. During this time, email delivery might be inconsistent.
If you encounter any issues, feel free to open an issue on the [GitHub repository](https://github.com/lanedirt/AliasVault/issues).
---
## 4. Troubleshooting
### Resetting the admin password
If you have lost your admin password, you can reset it by running the install script with the `reset-password` option. This will generate a new random password and update the .env file with it. After that it will restart the AliasVault containers to apply the changes.
```bash
./install.sh reset-password
```
### Verbose output
If you need more detailed output from the install script, you can run it with the `--verbose` option. This will print more information to the console.
```bash
./install.sh install --verbose
```
### No emails being received
If you are not receiving emails on your aliases, check the following:
- Verify DNS records are correctly configured
- Ensure ports 25 and 587 are accessible
- Check your server's firewall settings
- Verify that your ISP/hosting provider allows SMTP traffic

View File

@@ -2,7 +2,7 @@
layout: default
title: Start/stop
parent: Installation Guide
nav_order: 3
nav_order: 2
---
# Starting and stopping AliasVault

View File

@@ -2,7 +2,7 @@
layout: default
title: Uninstall
parent: Installation Guide
nav_order: 5
nav_order: 4
---
# Uninstall
@@ -16,4 +16,4 @@ This will not delete any data stored in the database. If you wish to delete all
1. Run the install script with the `uninstall` option
```bash
./install.sh uninstall
```
```

View File

@@ -2,7 +2,7 @@
layout: default
title: Update
parent: Installation Guide
nav_order: 4
nav_order: 3
---
# Updating AliasVault

View File

@@ -1,4 +1,5 @@
#!/bin/bash
# @version 0.8.2
# Repository information used for downloading files and images from GitHub
REPO_OWNER="lanedirt"
@@ -22,7 +23,6 @@ REQUIRED_DIRS=(
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m'
@@ -41,6 +41,7 @@ show_usage() {
printf " uninstall Uninstall AliasVault\n"
printf " update Update AliasVault to the latest version\n"
printf " configure-ssl Configure SSL certificates (Let's Encrypt or self-signed)\n"
printf " configure-email Configure email domains for receiving emails\n"
printf " start Start AliasVault containers\n"
printf " stop Stop AliasVault containers\n"
printf " restart Restart AliasVault containers\n"
@@ -94,6 +95,10 @@ parse_args() {
COMMAND="configure-ssl"
shift
;;
configure-email|email)
COMMAND="configure-email"
shift
;;
start|s)
COMMAND="start"
shift
@@ -172,6 +177,9 @@ main() {
"configure-ssl")
handle_ssl_configuration
;;
"configure-email")
handle_email_configuration
;;
"start")
handle_start
;;
@@ -258,8 +266,8 @@ print_logo() {
printf " _ _ _ __ __ _ _ \n"
printf " / \ | (_) __ _ ___ \ \ / /_ _ _ _| | |_\n"
printf " / _ \ | | |/ _\` / __| \ \/\/ / _\` | | | | | __|\n"
printf " / ___ \| | | (_| \__ \ \ / (_| | |_| | | |_ \n"
printf "/_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
printf " / ___ \| | | (_| \__ \ \ / / (_| | |_| | | |_ \n"
printf "/_/ \_\_|_|\__,_|___/ \/ \__,__|\__,_|_|\__|\n"
printf "${NC}\n"
}
@@ -316,22 +324,14 @@ populate_data_protection_cert_pass() {
set_private_email_domains() {
printf "${CYAN}> Checking PRIVATE_EMAIL_DOMAINS...${NC}\n"
if ! grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "Please enter the domains that should be allowed to receive email, separated by commas (press Enter to disable email support): "
read -r private_email_domains
update_env_var "PRIVATE_EMAIL_DOMAINS" "DISABLED.TLD"
fi
private_email_domains=${private_email_domains:-"DISABLED.TLD"}
update_env_var "PRIVATE_EMAIL_DOMAINS" "$private_email_domains"
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
printf " ${RED}SMTP is disabled.${NC}\n"
fi
private_email_domains=$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
printf " ${RED}Email server is disabled.${NC} To enable use ./install.sh configure-email command.\n"
else
private_email_domains=$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
printf " ${GREEN}> PRIVATE_EMAIL_DOMAINS already exists.${NC} ${RED}Private email domains are disabled.${NC}\n"
else
printf " ${GREEN}> PRIVATE_EMAIL_DOMAINS already exists.${NC}\n"
fi
printf " ${GREEN}> PRIVATE_EMAIL_DOMAINS already exists. Email server is enabled.${NC}\n"
fi
}
@@ -743,6 +743,147 @@ handle_ssl_configuration() {
esac
}
# Function to handle email server configuration
# Function to handle email server configuration
handle_email_configuration() {
# Setup trap for Ctrl+C and other interrupts
trap 'printf "\n${YELLOW}Configuration cancelled by user.${NC}\n"; exit 1' INT TERM
printf "${YELLOW}+++ Email Server 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 email domains from .env
CURRENT_DOMAINS=$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)
printf "${CYAN}About Email Server:${NC}\n"
printf "AliasVault includes a built-in email server for handling virtual email addresses.\n"
printf "When enabled, it can receive emails for one or more configured domains.\n"
printf "Each domain must have an MX record in DNS configuration pointing to this server's hostname.\n"
printf "\n"
printf "${CYAN}Current Configuration:${NC}\n"
if [ "$CURRENT_DOMAINS" = "DISABLED.TLD" ]; then
printf "Email Server Status: ${RED}Disabled${NC}\n"
else
printf "Email Server Status: ${GREEN}Enabled${NC}\n"
printf "Active Domains: ${CYAN}${CURRENT_DOMAINS}${NC}\n"
fi
printf "\n"
printf "Email Server Options:\n"
printf "1) Enable email server / Update domains\n"
printf "2) Disable email server\n"
printf "3) Cancel\n"
printf "\n"
read -p "Select an option [1-3]: " email_option
case $email_option in
1)
while true; do
printf "\n${CYAN}Enter domain(s) for email server${NC}\n"
printf "For multiple domains, separate with commas (e.g. domain1.com,domain2.com)\n"
printf "IMPORTANT: Each domain must have an MX record in DNS pointing to this server.\n"
read -p "Domains: " new_domains
if [ -z "$new_domains" ]; then
printf "${RED}Error: Domains cannot be empty${NC}\n"
continue
fi
printf "\n${CYAN}You entered the following domains:${NC}\n"
IFS=',' read -ra DOMAIN_ARRAY <<< "$new_domains"
for domain in "${DOMAIN_ARRAY[@]}"; do
printf " - ${GREEN}${domain}${NC}\n"
done
printf "\n"
read -p "Are these domains correct? (y/n): " confirm
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
break
fi
done
printf "\n${YELLOW}Warning: Docker containers need to be restarted to apply these changes.${NC}\n"
read -p "Continue with restart? (y/n): " restart_confirm
if [ "$restart_confirm" != "y" ] && [ "$restart_confirm" != "Y" ]; then
printf "${YELLOW}Configuration cancelled.${NC}\n"
exit 0
fi
# Update .env file and restart
if ! update_env_var "PRIVATE_EMAIL_DOMAINS" "$new_domains"; then
printf "${RED}Failed to update configuration.${NC}\n"
exit 1
fi
printf "${GREEN}Email server configuration updated${NC}\n"
printf "Restarting AliasVault services...\n"
if ! handle_restart; then
printf "${RED}Failed to restart services.${NC}\n"
exit 1
fi
# Only show next steps if everything succeeded
printf "\n${CYAN}The email server is now succesfully configured.${NC}\n"
printf "\n"
printf "To test the email server:\n"
printf " a. Log in to your AliasVault account\n"
printf " b. Create a new alias using one of your configured private domains\n"
printf " c. Send a test email from an external email service (e.g., Gmail)\n"
printf " d. Check if the email appears in your AliasVault inbox\n"
printf "\n"
printf "If emails don't arrive, please verify:\n"
printf " > DNS MX records are correctly configured\n"
printf " > Your server's firewall allows incoming traffic on port 25 and 587\n"
printf " > Your ISP/hosting provider doesn't block SMTP traffic\n"
printf "\n"
;;
2)
printf "${YELLOW}Warning: Docker containers need to be restarted after disabling the email server.${NC}\n"
read -p "Continue with disable and restart? (y/n): " disable_confirm
if [ "$disable_confirm" != "y" ] && [ "$disable_confirm" != "Y" ]; then
printf "${YELLOW}Configuration cancelled.${NC}\n"
exit 0
fi
# Disable email server
if ! update_env_var "PRIVATE_EMAIL_DOMAINS" "DISABLED.TLD"; then
printf "${RED}Failed to update configuration.${NC}\n"
exit 1
fi
printf "${YELLOW}Email server disabled${NC}\n"
printf "Restarting AliasVault services...\n"
if ! handle_restart; then
printf "${RED}Failed to restart services.${NC}\n"
exit 1
fi
;;
3)
printf "${YELLOW}Email configuration cancelled.${NC}\n"
exit 0
;;
*)
printf "${RED}Invalid option selected.${NC}\n"
exit 1
;;
esac
# Remove the trap before normal exit
trap - INT TERM
}
# Function to configure Let's Encrypt
configure_letsencrypt() {
printf "${CYAN}> Configuring Let's Encrypt SSL certificate...${NC}\n"
@@ -891,6 +1032,9 @@ handle_update() {
printf "${YELLOW}+++ Checking for AliasVault updates +++${NC}\n"
printf "\n"
# First check for install.sh updates
check_install_script_update || true
# Check current version
if ! grep -q "^ALIASVAULT_VERSION=" "$ENV_FILE"; then
printf "${YELLOW}> No version information found. Running first-time update check...${NC}\n"
@@ -906,8 +1050,8 @@ handle_update() {
exit 1
fi
printf "${CYAN}> Current version: ${current_version}${NC}\n"
printf "${CYAN}> Latest version: ${latest_version}${NC}\n"
printf "${CYAN}> Current AliasVault version: ${current_version}${NC}\n"
printf "${CYAN}> Latest AliasVault version: ${latest_version}${NC}\n"
printf "\n"
if [ "$current_version" = "$latest_version" ]; then
@@ -934,6 +1078,116 @@ handle_update() {
printf "${GREEN}> Update completed successfully!${NC}\n"
}
# Function to extract version
extract_version() {
local file="$1"
local version=$(head -n 2 "$file" | grep '@version' | cut -d' ' -f3)
echo "$version"
}
# Function to compare semantic versions
compare_versions() {
local version1="$1"
local version2="$2"
# Split versions into arrays
IFS='.' read -ra v1_parts <<< "$version1"
IFS='.' read -ra v2_parts <<< "$version2"
# Compare each part numerically
for i in {0..2}; do
# Default to 0 if part doesn't exist
local v1_part=${v1_parts[$i]:-0}
local v2_part=${v2_parts[$i]:-0}
# Compare numerically
if [ "$v1_part" -gt "$v2_part" ]; then
echo "1" # version1 is greater
return
elif [ "$v1_part" -lt "$v2_part" ]; then
echo "-1" # version1 is lesser
return
fi
done
echo "0" # versions are equal
}
# Function to check if install.sh needs updating
check_install_script_update() {
printf "${CYAN}> Checking for install script updates...${NC}\n"
# Download latest install.sh to temporary file
if ! curl -sSf "${GITHUB_RAW_URL}/install.sh" -o "install.sh.tmp"; then
printf "${RED}> Failed to check for install script updates. Continuing with current version.${NC}\n"
rm -f install.sh.tmp
return 1
fi
# Get versions
local current_version=$(extract_version "install.sh")
local new_version=$(extract_version "install.sh.tmp")
# Check if versions could be extracted
if [ -z "$current_version" ] || [ -z "$new_version" ]; then
printf "${YELLOW}> Could not determine script versions. Falling back to file comparison...${NC}\n"
# Fall back to file comparison
if ! cmp -s "install.sh" "install.sh.tmp"; then
printf "${YELLOW}> Changes detected in install script.${NC}\n"
else
printf "${GREEN}> Install script is up to date.${NC}\n"
rm -f install.sh.tmp
return 0
fi
else
printf "${CYAN}> Current install script version: ${current_version}${NC}\n"
printf "${CYAN}> Latest install script version: ${new_version}${NC}\n"
# Compare versions using semver comparison
if [ "$current_version" = "$new_version" ]; then
printf "${GREEN}> Install script is up to date.${NC}\n"
rm -f install.sh.tmp
return 0
else
local compare_result=$(compare_versions "$current_version" "$new_version")
if [ "$compare_result" -ge "0" ]; then
printf "${GREEN}> Install script is up to date.${NC}\n"
rm -f install.sh.tmp
return 0
fi
fi
fi
# If we get here, an update is available
printf "${YELLOW}> A new version of the install script is available.${NC}\n"
printf "Would you like to update the install script before proceeding? [Y/n]: "
read -r reply
if [[ ! $reply =~ ^[Nn]$ ]]; then
# Create backup of current script
cp "install.sh" "install.sh.backup"
if mv "install.sh.tmp" "install.sh"; then
chmod +x "install.sh"
printf "${GREEN}> Install script updated successfully.${NC}\n"
printf "${GREEN}> Backup of previous version saved as install.sh.backup${NC}\n"
printf "${YELLOW}> Please run the update command again to continue with the update process.${NC}\n"
exit 0
else
printf "${RED}> Failed to update install script. Continuing with current version.${NC}\n"
# Restore from backup if update failed
mv "install.sh.backup" "install.sh"
rm -f install.sh.tmp
return 1
fi
else
printf "${YELLOW}> Continuing with current install script version.${NC}\n"
rm -f install.sh.tmp
return 0
fi
}
# Function to perform the actual installation with specific version
handle_install_version() {
local target_version="$1"

View File

@@ -262,16 +262,14 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
return Unauthorized("User not found (name-2)");
}
// Check if the refresh token is valid.
var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.Value == tokenModel.RefreshToken);
if (existingToken == null || existingToken.ExpireDate < timeProvider.UtcNow)
// Generate new tokens for the user.
var token = await GenerateNewTokensForUser(user, tokenModel.RefreshToken);
if (token == null)
{
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken);
return Unauthorized("Refresh token expired");
return Unauthorized("Invalid refresh token");
}
// Generate new tokens for the user.
var token = await GenerateNewTokensForUser(user, existingToken);
await context.SaveChangesAsync();
await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.TokenRefresh);
@@ -345,7 +343,7 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
UserName = model.Username,
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
PasswordChangedAt = DateTime.UtcNow,
PasswordChangedAt = timeProvider.UtcNow,
};
user.Vaults.Add(new AliasServerDb.Vault
@@ -459,6 +457,7 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
private static (bool IsValid, string ErrorMessage) ValidateUsername(string username)
{
const int minimumUsernameLength = 3;
const int maximumUsernameLength = 40;
const string adminUsername = "admin";
if (string.IsNullOrWhiteSpace(username))
@@ -468,7 +467,12 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
if (username.Length < minimumUsernameLength)
{
return (false, $"Username must be at least {minimumUsernameLength} characters long.");
return (false, $"Username too short: must be at least {minimumUsernameLength} characters long.");
}
if (username.Length > maximumUsernameLength)
{
return (false, $"Username too long: cannot be longer than {maximumUsernameLength} characters.");
}
if (string.Equals(username, adminUsername, StringComparison.OrdinalIgnoreCase))
@@ -678,9 +682,9 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
/// to the database.
/// </summary>
/// <param name="user">The user to generate the tokens for.</param>
/// <param name="existingToken">The existing token that is being replaced (optional).</param>
/// <returns>TokenModel which includes new access and refresh token.</returns>
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, AliasVaultUserRefreshToken existingToken)
/// <param name="existingTokenValue">The existing token value that is being replaced (optional).</param>
/// <returns>TokenModel which includes new access and refresh token. Returns null if provided refresh token is invalid.</returns>
private async Task<TokenModel?> GenerateNewTokensForUser(AliasVaultUser user, string existingTokenValue)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
await Semaphore.WaitAsync();
@@ -693,7 +697,7 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
var existingTokenReuseWindow = timeProvider.UtcNow.AddSeconds(-30);
var existingTokenReuse = await context.AliasVaultUserRefreshTokens
.FirstOrDefaultAsync(t => t.UserId == user.Id &&
t.PreviousTokenValue == existingToken.Value &&
t.PreviousTokenValue == existingTokenValue &&
t.CreatedAt > existingTokenReuseWindow);
if (existingTokenReuse is not null)
@@ -704,15 +708,14 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
return new TokenModel { Token = accessToken, RefreshToken = existingTokenReuse.Value };
}
// Remove the existing refresh token.
var tokenToDelete = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.Id == existingToken.Id);
if (tokenToDelete is null)
// Check if the refresh token still exists and is not expired.
var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.Value == existingTokenValue);
if (existingToken == null || existingToken.ExpireDate < timeProvider.UtcNow)
{
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken);
throw new InvalidOperationException("Refresh token does not exist (anymore).");
return null;
}
context.AliasVaultUserRefreshTokens.Remove(tokenToDelete);
context.AliasVaultUserRefreshTokens.Remove(existingToken);
// New refresh token lifetime is the same as the existing one.
var existingTokenLifetime = existingToken.ExpireDate - existingToken.CreatedAt;

View File

@@ -26,7 +26,7 @@
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
</div>
<div class="flex justify-end items-center space-x-2">
@if (RefreshTimer is not null)
@if (DbService.Settings.AutoEmailRefresh)
{
<div class="w-3 h-3 mr-2 rounded-full bg-primary-300 border-2 border-primary-100 animate-pulse" title="Auto-refresh enabled"></div>
}
@@ -56,27 +56,27 @@
<div class="overflow-hidden shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
</th>
</tr>
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</span>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem</span>
</td>
</tr>
}
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</span>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem</span>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -99,13 +99,56 @@
private EmailApiModel Email { get; set; } = new();
private bool EmailModalVisible { get; set; }
private string Error { get; set; } = string.Empty;
private Timer? RefreshTimer { get; set; }
private bool IsRefreshing { get; set; } = true;
private bool IsLoading { get; set; } = true;
private bool IsSpamOk { get; set; } = false;
private bool IsPageVisible { get; set; } = true;
private CancellationTokenSource? PollingCancellationTokenSource { get; set; }
private const int ACTIVE_TAB_REFRESH_INTERVAL = 2000; // 2 seconds
private readonly SemaphoreSlim RefreshSemaphore = new(1, 1);
private DateTime LastRefreshTime = DateTime.MinValue;
/// <summary>
/// Callback invoked by JavaScript when the page visibility changes.
/// </summary>
/// <param name="isVisible">Boolean whether the page is visible or not.</param>
/// <returns>Task.</returns>
[JSInvokable]
public async Task OnVisibilityChange(bool isVisible)
{
IsPageVisible = isVisible;
if (isVisible)
{
// Only enable auto-refresh if the setting is enabled.
if (DbService.Settings.AutoEmailRefresh)
{
await StartPolling();
}
// Refresh immediately when tab becomes visible
await ManualRefresh();
}
else
{
// Cancel polling.
if (PollingCancellationTokenSource is not null)
{
await PollingCancellationTokenSource.CancelAsync();
}
}
StateHasChanged();
}
/// <inheritdoc />
public void Dispose()
{
PollingCancellationTokenSource?.Cancel();
PollingCancellationTokenSource?.Dispose();
RefreshSemaphore.Dispose();
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -124,12 +167,29 @@
}
IsSpamOk = IsSpamOkDomain(EmailAddress);
// Set up visibility change detection
await JsInteropService.RegisterVisibilityCallback(DotNetObjectReference.Create(this));
// Only enable auto-refresh if the setting is enabled.
if (DbService.Settings.AutoEmailRefresh)
{
RefreshTimer = new Timer(2000);
RefreshTimer.Elapsed += async (sender, e) => await TimerRefresh();
RefreshTimer.Start();
await StartPolling();
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!ShowComponent)
{
return;
}
if (firstRender)
{
await ManualRefresh();
}
}
@@ -146,25 +206,62 @@
IsSpamOk = IsSpamOkDomain(EmailAddress);
}
/// <inheritdoc />
public void Dispose()
/// <summary>
/// Start the polling for new emails.
/// </summary>
/// <returns>Task.</returns>
private async Task StartPolling()
{
RefreshTimer?.Dispose();
if (PollingCancellationTokenSource is not null)
{
await PollingCancellationTokenSource.CancelAsync();
}
PollingCancellationTokenSource = new CancellationTokenSource();
try
{
while (!PollingCancellationTokenSource.Token.IsCancellationRequested)
{
if (IsPageVisible)
{
// Only auto refresh when the tab is visible.
await RefreshWithThrottling();
await Task.Delay(ACTIVE_TAB_REFRESH_INTERVAL, PollingCancellationTokenSource.Token);
}
}
}
catch (OperationCanceledException)
{
// Normal cancellation, ignore
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
/// <summary>
/// Refresh the emails with throttling to prevent multiple refreshes at the same time.
/// </summary>
/// <returns></returns>
private async Task RefreshWithThrottling()
{
await base.OnAfterRenderAsync(firstRender);
if (!ShowComponent)
if (!await RefreshSemaphore.WaitAsync(0)) // Don't wait if a refresh is in progress
{
return;
}
if (firstRender)
try
{
await ManualRefresh();
var timeSinceLastRefresh = DateTime.UtcNow - LastRefreshTime;
if (timeSinceLastRefresh.TotalMilliseconds < ACTIVE_TAB_REFRESH_INTERVAL)
{
return;
}
await LoadRecentEmailsAsync();
LastRefreshTime = DateTime.UtcNow;
}
finally
{
RefreshSemaphore.Release();
}
}
@@ -184,15 +281,10 @@
return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x));
}
private async Task TimerRefresh()
{
IsRefreshing = true;
StateHasChanged();
await LoadRecentEmailsAsync();
IsRefreshing = false;
StateHasChanged();
}
/// <summary>
/// Manually refresh the emails.
/// </summary>
/// <returns></returns>
private async Task ManualRefresh()
{
IsLoading = true;
@@ -202,6 +294,10 @@
StateHasChanged();
}
/// <summary>
/// (Re)load recent emails by making an API call to the server.
/// </summary>
/// <returns>Task.</returns>
private async Task LoadRecentEmailsAsync()
{
if (!ShowComponent || EmailAddress is null)

View File

@@ -9,6 +9,7 @@ namespace AliasVault.Client.Services;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.JSInterop;
/// <summary>
@@ -237,6 +238,16 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
await jsRuntime.InvokeVoidAsync("window.scrollTo", 0, 0);
}
/// <summary>
/// Registers a visibility callback which is invoked when the visibility of component changes in client.
/// </summary>
/// <typeparam name="TComponent">Component type.</typeparam>
/// <param name="objRef">DotNetObjectReference.</param>
/// <returns>Task.</returns>
public async Task RegisterVisibilityCallback<TComponent>(DotNetObjectReference<TComponent> objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.registerVisibilityCallback", objRef);
/// <summary>
/// Represents the result of a WebAuthn get credential operation.
/// </summary>

View File

@@ -298,3 +298,9 @@ async function createWebAuthnCredentialAndDeriveKey(username) {
return { Error: "WEBAUTHN_CREATE_ERROR", Message: createError.message };
}
}
window.registerVisibilityCallback = function (dotnetHelper) {
document.addEventListener("visibilitychange", function () {
dotnetHelper.invokeMethodAsync('OnVisibilityChange', !document.hidden);
});
};

View File

@@ -30,7 +30,7 @@ public static class AppInfo
/// <summary>
/// Gets the patch version number.
/// </summary>
public const int VersionPatch = 1;
public const int VersionPatch = 2;
/// <summary>
/// Gets the build number, typically used in CI/CD pipelines.

View File

@@ -29,7 +29,7 @@ public abstract class PlaywrightTest
/// <summary>
/// Gets or sets random unique account email that is used for the test.
/// </summary>
protected virtual string TestUserUsername { get; set; } = $"{Guid.NewGuid()}@test.com";
protected virtual string TestUserUsername { get; set; } = $"{Guid.NewGuid().ToString()[..10]}@test.com";
/// <summary>
/// Gets or sets random unique account password that is used for the test.
@@ -201,7 +201,7 @@ public abstract class PlaywrightTest
/// </summary>
protected void SetRandomTestUserCredentials()
{
TestUserUsername = $"{Guid.NewGuid()}@test.com";
TestUserUsername = $"{Guid.NewGuid().ToString()[..10]}@test.com";
TestUserPassword = Guid.NewGuid().ToString();
}

View File

@@ -116,4 +116,55 @@ public class UserSetupTests : ClientPlaywrightTest
var errorMessage = await WaitForAndGetElement("text='Username is already in use.'");
Assert.That(errorMessage, Is.Not.Null, "The 'Username is already in use' error message should appear.");
}
/// <summary>
/// Test if the "Username too short" and "Username too long" error appears when trying to register with an invalid username.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(3)]
public async Task UserSetupUsernameLengthTest()
{
// Logout.
await Logout();
await Page.GotoAsync(AppBaseUrl);
await WaitForUrlAsync("user/start", "Create new vault");
// Click the "Create new vault" anchor tag.
var createVaultButton = await WaitForAndGetElement("a:has-text('Create new vault')");
await createVaultButton.ClickAsync();
// Wait for the terms and conditions to load.
await WaitForUrlAsync("user/setup", "Terms and Conditions");
// Accept the terms and conditions.
var acceptTermsCheckbox = await WaitForAndGetElement("input[id='agreeTerms']");
await acceptTermsCheckbox.CheckAsync();
// Wait for the continue button to be enabled.
await Task.Delay(100);
// Press the continue button.
var continueButton = await WaitForAndGetElement("button:has-text('Continue')");
await continueButton.ClickAsync();
// Wait for the username step to load.
await WaitForUrlAsync("user/setup", "Username");
var usernameField = await WaitForAndGetElement("input[id='username']");
await usernameField.FillAsync("ts"); // Too short username (2 chars)
// Check if the "Username is too short" error message appears
var errorMessage = await WaitForAndGetElement("text='Username too short: must be at least 3 characters long.'");
Assert.That(errorMessage, Is.Not.Null, "The 'Username too short' error message should appear.");
// Clear the username field.
await usernameField.FillAsync(string.Empty);
// Fill in a too long username (41 chars).
await usernameField.FillAsync("asdasdasdasdasdasdasdasdasdaaaasasddsdasd"); // Too long username (41 chars)
// Check if the "Username is too short" error message appears
errorMessage = await WaitForAndGetElement("text='Username too long: cannot be longer than 40 characters.'");
Assert.That(errorMessage, Is.Not.Null, "The 'Username too long' error message should appear.");
}
}

View File

@@ -12,28 +12,6 @@ namespace AliasVault.Tests.Utilities;
/// </summary>
public class FaviconExtractorTests
{
/// <summary>
/// Test extracting a favicon from a known website.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task ExtractFaviconSpamOk()
{
var faviconBytes = await FaviconExtractor.FaviconExtractor.GetFaviconAsync("https://spamok.com");
Assert.That(faviconBytes, Is.Not.Null);
}
/// <summary>
/// Test extracting a favicon from a known website.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task ExtractFaviconDumpert()
{
var faviconBytes = await FaviconExtractor.FaviconExtractor.GetFaviconAsync("https://www.dumpert.nl");
Assert.That(faviconBytes, Is.Not.Null);
}
/// <summary>
/// Test extracting a favicon from a known website.
/// </summary>