Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4386f53f5 | |||
| 8f443379cd | |||
| a47b57f26a | |||
| cd401e108f | |||
| 3d11470f81 | |||
| 877ecf32b4 | |||
| 3b78959901 | |||
| 6e110289cd | |||
| afab617c5c | |||
| b737826d11 | |||
| f9704f6dd3 | |||
| 75a3ec9d7e | |||
| 21f27e0d27 | |||
| 7ed6100f05 | |||
| c3efa127d9 | |||
| e4fed12143 | |||
| 7fc81a0f65 | |||
| 611031ec6c | |||
| 686bce2df9 | |||
| efbb5725d3 | |||
| d444bb5fcb | |||
| acd37a9c15 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,3 +14,5 @@ BUI*
|
||||
build.sh
|
||||
docker-compose.dist*
|
||||
GITEA.md
|
||||
PROXY_AUTHELIA_SETUP.md
|
||||
alias.rune.pm.config
|
||||
12
Dockerfile
12
Dockerfile
@@ -11,17 +11,25 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY malias_wrapper.py .
|
||||
COPY malias.py .
|
||||
COPY reset_password.py .
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy entrypoint script and make scripts executable
|
||||
COPY docker-entrypoint.sh .
|
||||
RUN chmod +x docker-entrypoint.sh reset_password.py
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5172
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_DEBUG=False
|
||||
ENV FLASK_ENV=production
|
||||
ENV ENABLE_PROXY=false
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "app.py"]
|
||||
# Run the application via entrypoint
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
||||
432
PROXY_SETUP.md
Normal file
432
PROXY_SETUP.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Reverse Proxy Setup Guide
|
||||
|
||||
This guide helps you configure reverse proxies (Nginx, Traefik, Zoraxy, Authelia, Caddy, etc.) to work with Mailcow Alias Manager.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 ENABLE_PROXY Configuration
|
||||
|
||||
The application supports **two access modes** controlled by the `ENABLE_PROXY` environment variable in `docker-compose.yml`:
|
||||
|
||||
### **Mode 1: Direct Access (ENABLE_PROXY=false)** - DEFAULT
|
||||
|
||||
Use this when accessing the application directly via IP:port **without a reverse proxy**.
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
environment:
|
||||
- ENABLE_PROXY=false # Default setting
|
||||
```
|
||||
|
||||
**Access:** `http://192.168.1.100:5172` (replace with your server IP)
|
||||
|
||||
**Features:**
|
||||
- ✅ Works over HTTP (no HTTPS required)
|
||||
- ✅ Standard cookie behavior (SameSite=Lax)
|
||||
- ✅ No proxy configuration needed
|
||||
- ✅ Simple login flow
|
||||
- ✅ Perfect for internal/LAN access
|
||||
|
||||
**When to use:**
|
||||
- Accessing from internal network only
|
||||
- No reverse proxy in place
|
||||
- Testing or development
|
||||
- Simple single-server setup
|
||||
|
||||
---
|
||||
|
||||
### **Mode 2: Proxy Access (ENABLE_PROXY=true)**
|
||||
|
||||
Use this when running **behind a reverse proxy** (Authelia, Zoraxy, Nginx, Traefik, Caddy).
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
environment:
|
||||
- ENABLE_PROXY=true
|
||||
```
|
||||
|
||||
**Access:** `https://alias.yourdomain.com` (through your reverse proxy)
|
||||
|
||||
**Features:**
|
||||
- ✅ ProxyFix middleware handles X-Forwarded-* headers
|
||||
- ✅ HTTPS redirect support
|
||||
- ✅ Secure cookies (HTTPS only)
|
||||
- ✅ Works with authentication proxies (Authelia)
|
||||
- ✅ Multi-proxy chain support
|
||||
|
||||
**When to use:**
|
||||
- Accessing from internet via domain name
|
||||
- Behind Nginx, Traefik, Caddy, HAProxy
|
||||
- Behind authentication proxy (Authelia, Authentik)
|
||||
- SSL/TLS termination at proxy
|
||||
- Production deployments with HTTPS
|
||||
|
||||
**Special Feature - Authelia Auto-Login:**
|
||||
When `ENABLE_PROXY=true` and you're using Authelia, the app automatically logs you in using Authelia's authentication headers. **No app password needed!** Simply authenticate through Authelia, and you'll be logged into the Mailcow Alias Manager automatically.
|
||||
|
||||
---
|
||||
|
||||
### **Switching Between Modes**
|
||||
|
||||
To switch from one mode to another:
|
||||
|
||||
1. **Edit `docker-compose.yml`**
|
||||
```yaml
|
||||
# Change this line:
|
||||
- ENABLE_PROXY=false # or true
|
||||
```
|
||||
|
||||
2. **Restart the container**
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. **Verify mode in logs**
|
||||
```bash
|
||||
docker compose logs mailcow-alias-manager | grep "ACCESS MODE"
|
||||
```
|
||||
|
||||
You should see either:
|
||||
- `ACCESS MODE: Direct IP:Port (ENABLE_PROXY=false)`
|
||||
- `ACCESS MODE: Reverse Proxy (ENABLE_PROXY=true)`
|
||||
|
||||
4. **Clear browser cookies** (IMPORTANT!)
|
||||
- Press F12 → Application → Cookies
|
||||
- Delete all cookies for your domain
|
||||
- Close and reopen browser
|
||||
|
||||
5. **Login again**
|
||||
|
||||
---
|
||||
|
||||
### **Quick Reference Table**
|
||||
|
||||
| Access Method | ENABLE_PROXY | Access URL | Cookie Mode |
|
||||
|--------------|--------------|------------|-------------|
|
||||
| Direct IP:port | `false` (default) | `http://192.168.1.100:5172` | HTTP, SameSite=Lax |
|
||||
| Nginx/Traefik | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
|
||||
| Authelia + Zoraxy | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
|
||||
| Caddy | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
|
||||
| Local dev (`python3 app.py`) | N/A (not set) | `http://localhost:5172` | HTTP, SameSite=Lax |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Built-in Proxy Support (When ENABLE_PROXY=true)
|
||||
|
||||
The application includes **ProxyFix middleware** that automatically handles:
|
||||
- ✅ HTTPS detection via `X-Forwarded-Proto`
|
||||
- ✅ Host header forwarding via `X-Forwarded-Host`
|
||||
- ✅ Client IP forwarding via `X-Forwarded-For`
|
||||
- ✅ Path prefix support via `X-Forwarded-Prefix`
|
||||
|
||||
**Works with 2 proxies in chain** (e.g., Zoraxy → Authelia → App)
|
||||
|
||||
---
|
||||
|
||||
## Common Proxy Configurations
|
||||
|
||||
### **Zoraxy** (Your Current Setup)
|
||||
|
||||
Zoraxy automatically sets the required headers. Just configure:
|
||||
|
||||
1. **Upstream Target**: `http://localhost:5172` (or your Docker IP)
|
||||
2. **Enable WebSocket**: Yes (optional, recommended)
|
||||
3. **Enable Proxy Headers**: Yes (should be default)
|
||||
|
||||
**Example Zoraxy Config:**
|
||||
```json
|
||||
{
|
||||
"name": "mailcow-alias-manager",
|
||||
"upstream": "http://localhost:5172",
|
||||
"domain": "aliases.yourdomain.com"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Authelia** (Authentication Proxy)
|
||||
|
||||
If using Authelia in front of the app:
|
||||
|
||||
1. **Authelia forwards the user after login** - this should work automatically
|
||||
2. **Make sure Authelia passes these headers**:
|
||||
- `X-Forwarded-Proto`
|
||||
- `X-Forwarded-Host`
|
||||
- `X-Forwarded-For`
|
||||
|
||||
**Common Issue**: Authelia redirects → App login page
|
||||
**Solution**: The app has its own authentication. You have two options:
|
||||
- **Option A**: Disable app login (requires code modification)
|
||||
- **Option B**: Use Authelia for network access, app login for API access
|
||||
|
||||
---
|
||||
|
||||
### **Nginx**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name aliases.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5172;
|
||||
|
||||
# Required headers for ProxyFix
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Optional: WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Traefik**
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
services:
|
||||
mailcow-alias-manager:
|
||||
image: gitlab.pm/rune/malias-web:latest
|
||||
container_name: mailcow-alias-manager
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.malias.rule=Host(`aliases.yourdomain.com`)"
|
||||
- "traefik.http.routers.malias.entrypoints=websecure"
|
||||
- "traefik.http.routers.malias.tls=true"
|
||||
- "traefik.http.routers.malias.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.malias.loadbalancer.server.port=5172"
|
||||
networks:
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
```
|
||||
|
||||
Traefik automatically sets `X-Forwarded-*` headers by default.
|
||||
|
||||
---
|
||||
|
||||
### **Caddy**
|
||||
|
||||
```caddy
|
||||
aliases.yourdomain.com {
|
||||
reverse_proxy localhost:5172
|
||||
}
|
||||
```
|
||||
|
||||
Caddy automatically handles all proxy headers - no configuration needed!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### **Issue: "NetworkError when attempting to fetch resource"**
|
||||
|
||||
**Causes:**
|
||||
1. Mixed HTTP/HTTPS content
|
||||
2. CORS blocking requests
|
||||
3. Session cookie not being set
|
||||
4. Proxy not forwarding headers
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### **1. Check Proxy Headers**
|
||||
Your proxy MUST forward these headers:
|
||||
```
|
||||
X-Forwarded-Proto: https
|
||||
X-Forwarded-Host: aliases.yourdomain.com
|
||||
X-Forwarded-For: <client-ip>
|
||||
```
|
||||
|
||||
#### **2. Check Browser Console**
|
||||
Open browser DevTools (F12) → Console tab → Look for errors:
|
||||
- **CORS errors**: Proxy misconfiguration
|
||||
- **Mixed content**: HTTP resources on HTTPS page
|
||||
- **401 Unauthorized**: Session/cookie issue
|
||||
|
||||
#### **3. Test Direct Access**
|
||||
```bash
|
||||
# Test without proxy
|
||||
curl http://localhost:5172/
|
||||
|
||||
# Should return HTML (login page)
|
||||
```
|
||||
|
||||
If this works but proxy doesn't, it's a proxy configuration issue.
|
||||
|
||||
#### **4. Check Session Cookies**
|
||||
In browser DevTools → Application tab → Cookies:
|
||||
- **Domain**: Should match your proxy domain
|
||||
- **Path**: `/`
|
||||
- **HttpOnly**: Should be `true`
|
||||
- **Secure**: Depends on your setup
|
||||
|
||||
---
|
||||
|
||||
### **Issue: Login works but redirects to HTTP instead of HTTPS**
|
||||
|
||||
**Solution**: Already fixed in latest version via `PREFERRED_URL_SCHEME='https'` in app config.
|
||||
|
||||
If still happening:
|
||||
1. Verify proxy sends `X-Forwarded-Proto: https`
|
||||
2. Check `docker compose logs -f mailcow-alias-manager`
|
||||
3. Rebuild image: `docker compose up -d --build`
|
||||
|
||||
---
|
||||
|
||||
### **Issue: "Invalid password" but password is correct**
|
||||
|
||||
This is NOT a proxy issue - this is an authentication issue:
|
||||
```bash
|
||||
# Reset password
|
||||
docker exec -it mailcow-alias-manager python3 reset_password.py "newpass"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Issue: 502 Bad Gateway**
|
||||
|
||||
**Causes:**
|
||||
1. App not running
|
||||
2. Wrong upstream port
|
||||
3. Container network issue
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
# Is container running?
|
||||
docker ps | grep mailcow-alias-manager
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f mailcow-alias-manager
|
||||
|
||||
# Test internal connectivity
|
||||
docker exec mailcow-alias-manager curl http://localhost:5172
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### **1. HTTPS Only**
|
||||
Always use HTTPS in production. HTTP is only for local testing.
|
||||
|
||||
### **2. Restrict Access**
|
||||
Use firewall or proxy rules to restrict access:
|
||||
```nginx
|
||||
# Nginx: Restrict to internal network only
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
```
|
||||
|
||||
### **3. Use Authelia/Authentik**
|
||||
Add SSO layer for additional security:
|
||||
- Users authenticate via Authelia
|
||||
- Then app login provides API access
|
||||
- Two-factor authentication recommended
|
||||
|
||||
### **4. Keep Session Cookies Secure**
|
||||
The app sets:
|
||||
- `HttpOnly=True` - Prevents JavaScript access
|
||||
- `SameSite=Lax` - CSRF protection
|
||||
|
||||
For HTTPS-only deployments, you can enforce secure cookies:
|
||||
```python
|
||||
# In app.py, change:
|
||||
SESSION_COOKIE_SECURE=True # Only send cookie over HTTPS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Testing Your Setup
|
||||
|
||||
### **1. Basic Connectivity**
|
||||
```bash
|
||||
curl -v https://aliases.yourdomain.com/
|
||||
# Should return 200 OK with HTML
|
||||
```
|
||||
|
||||
### **2. Login Test**
|
||||
```bash
|
||||
curl -v -X POST https://aliases.yourdomain.com/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"yourpassword"}' \
|
||||
-c cookies.txt
|
||||
|
||||
# Check response - should be JSON with status: success
|
||||
```
|
||||
|
||||
### **3. Session Test**
|
||||
```bash
|
||||
curl -v https://aliases.yourdomain.com/ \
|
||||
-b cookies.txt
|
||||
|
||||
# Should return main page HTML (not login redirect)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Still Having Issues?
|
||||
|
||||
### **Enable Debug Logging**
|
||||
|
||||
**docker-compose.yml:**
|
||||
```yaml
|
||||
environment:
|
||||
- FLASK_DEBUG=True # Enable debug mode
|
||||
```
|
||||
|
||||
Restart:
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### **Check Application Logs**
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f mailcow-alias-manager
|
||||
|
||||
# Check for errors
|
||||
docker compose logs mailcow-alias-manager | grep -i error
|
||||
```
|
||||
|
||||
### **Test Without Proxy**
|
||||
Temporarily bypass proxy to isolate the issue:
|
||||
```bash
|
||||
# Direct access test
|
||||
curl http://localhost:5172/
|
||||
```
|
||||
|
||||
If this works, the issue is with proxy configuration, not the app.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Summary
|
||||
|
||||
**The app is already configured for reverse proxies!**
|
||||
|
||||
Key points:
|
||||
- ✅ ProxyFix middleware is enabled
|
||||
- ✅ Handles X-Forwarded-* headers automatically
|
||||
- ✅ HTTPS redirect support built-in
|
||||
- ✅ Session cookies work behind proxies
|
||||
- ✅ No code changes needed
|
||||
|
||||
Just make sure your proxy forwards the standard headers and you're good to go! 🚀
|
||||
29
README.md
29
README.md
@@ -1,4 +1,4 @@
|
||||
# Mailcow Alias Manager Web Interface
|
||||
# Mailcow Alias Manager Web Interface..
|
||||
|
||||
A Python-based web application for managing mail aliases on a Mailcow instance. Built with Flask and a dark theme interface for internal network use. This application uses [malias](https://gitlab.pm/rune/malias) as base. Malias it self is a CLI application for managing aliases on a Mailcow Instance. If you already are using the CLI instance you can copy the the DB file over to your `/data` directory and you'll keep you API key etc. If you start with a blank DB you can use the `Sync Aliases` function to populate existing aliases to your local db.
|
||||
|
||||
@@ -68,7 +68,32 @@ The application will be available at: `http://localhost:5172`
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Configure Mailcow Connection
|
||||
### 1. Access Mode Configuration
|
||||
|
||||
The application supports two access modes:
|
||||
|
||||
**Direct Access (Default)** - Access via IP:port
|
||||
- Set `ENABLE_PROXY=false` in `docker-compose.yml` (default)
|
||||
- Access at: `http://your-server-ip:5172`
|
||||
- Works over HTTP (no HTTPS required)
|
||||
- **Login:** Enter app password
|
||||
- Perfect for internal/LAN access
|
||||
|
||||
**Proxy Access** - Access via reverse proxy (Authelia, Nginx, Traefik, etc.)
|
||||
- Set `ENABLE_PROXY=true` in `docker-compose.yml`
|
||||
- Access at: `https://alias.yourdomain.com` (through your proxy)
|
||||
- Requires HTTPS
|
||||
- **Login:** Automatic when using Authelia (no password needed!)
|
||||
- See [PROXY_SETUP.md](PROXY_SETUP.md) for detailed configuration
|
||||
|
||||
**To switch modes:**
|
||||
1. Edit `docker-compose.yml` and change `ENABLE_PROXY` value
|
||||
2. Restart: `docker compose down && docker compose up -d`
|
||||
3. Clear browser cookies and login again
|
||||
|
||||
---
|
||||
|
||||
### 2. Configure Mailcow Connection
|
||||
|
||||
You can configure your Mailcow server connection either:
|
||||
|
||||
|
||||
600
app.py
600
app.py
@@ -1,51 +1,350 @@
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, session, make_response
|
||||
from functools import wraps
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from datetime import timedelta
|
||||
import malias_wrapper as malias_w
|
||||
import os
|
||||
import argparse
|
||||
import sys
|
||||
import secrets
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger('mailcow-alias-manager')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24) # Secret key for session management
|
||||
app.secret_key = os.getenv('SECRET_KEY', 'malias-default-secret-key-please-change') # Consistent secret key
|
||||
|
||||
# Configure proxy mode based on environment variable
|
||||
# ENABLE_PROXY=true: Behind reverse proxy (Authelia/Zoraxy/Nginx/etc.)
|
||||
# ENABLE_PROXY=false: Direct IP:port access (default)
|
||||
ENABLE_PROXY = os.getenv('ENABLE_PROXY', 'false').lower() in ('true', '1', 'yes')
|
||||
|
||||
if ENABLE_PROXY:
|
||||
# Mode: Behind reverse proxy (Authelia/Zoraxy/Nginx/etc.)
|
||||
logger.info("=" * 60)
|
||||
logger.info(" ACCESS MODE: Reverse Proxy (ENABLE_PROXY=true)")
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Configuration:")
|
||||
logger.info(" - ProxyFix middleware: ACTIVE")
|
||||
logger.info(" - Trusted proxies: 2 (e.g., Zoraxy + Authelia)")
|
||||
logger.info(" - Cookie security: HTTPS only (Secure=True)")
|
||||
logger.info(" - SameSite policy: None (cross-origin auth)")
|
||||
logger.info(" - URL scheme: https://")
|
||||
logger.info("=" * 60)
|
||||
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=2, # Trust X-Forwarded-For with 2 proxies
|
||||
x_proto=2, # Trust X-Forwarded-Proto (http/https)
|
||||
x_host=2, # Trust X-Forwarded-Host
|
||||
x_prefix=2 # Trust X-Forwarded-Prefix
|
||||
)
|
||||
|
||||
cookie_secure = True
|
||||
cookie_samesite = 'None'
|
||||
preferred_scheme = 'https'
|
||||
else:
|
||||
# Mode: Direct IP:port access (no proxy)
|
||||
logger.info("=" * 60)
|
||||
logger.info(" ACCESS MODE: Direct IP:Port (ENABLE_PROXY=false)")
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Configuration:")
|
||||
logger.info(" - ProxyFix middleware: DISABLED")
|
||||
logger.info(" - Cookie security: HTTP allowed (Secure=False)")
|
||||
logger.info(" - SameSite policy: Lax (standard mode)")
|
||||
logger.info(" - URL scheme: http://")
|
||||
logger.info(" - Access via: http://your-ip:5172")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Don't apply ProxyFix for direct access
|
||||
cookie_secure = False
|
||||
cookie_samesite = 'Lax'
|
||||
preferred_scheme = 'http'
|
||||
|
||||
# Initialize database on startup
|
||||
malias_w.init_database()
|
||||
|
||||
# Session configuration - dynamic based on proxy mode
|
||||
app.config.update(
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
|
||||
SESSION_COOKIE_NAME='malias_session',
|
||||
SESSION_COOKIE_SECURE=cookie_secure,
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE=cookie_samesite,
|
||||
SESSION_COOKIE_PATH='/',
|
||||
SESSION_COOKIE_DOMAIN=None,
|
||||
SESSION_REFRESH_EACH_REQUEST=True,
|
||||
PREFERRED_URL_SCHEME=preferred_scheme
|
||||
)
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
"""Process the response before it's sent"""
|
||||
if ENABLE_PROXY:
|
||||
# Set CORS headers for proxy mode (needed for Authelia/Zoraxy)
|
||||
response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
|
||||
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
|
||||
# Cache control (applies to both modes)
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
|
||||
return response
|
||||
|
||||
def get_authelia_user():
|
||||
"""Helper to get authenticated user from Authelia headers or session cookie"""
|
||||
# Check several possible header variations (ORDER MATTERS - most specific first!)
|
||||
auth_headers = [
|
||||
'X-Authelia-Username', # Authelia standard header (used by Zoraxy)
|
||||
'Remote-User', # Common standard
|
||||
'X-Remote-User',
|
||||
'X-Forwarded-User',
|
||||
'REMOTE_USER',
|
||||
'Http-Remote-User',
|
||||
'Http-X-Remote-User',
|
||||
'X-Authenticated-User'
|
||||
]
|
||||
|
||||
for header in auth_headers:
|
||||
user = request.headers.get(header)
|
||||
if user:
|
||||
logger.info(f"✅ Authelia user detected via header '{header}': {user}")
|
||||
return user
|
||||
|
||||
# Check Zoraxy forwarded headers (sometimes encoded differently)
|
||||
if 'X-Forwarded-Headers' in request.headers:
|
||||
try:
|
||||
# Some reverse proxies encode headers as JSON
|
||||
fwd_headers = json.loads(request.headers.get('X-Forwarded-Headers'))
|
||||
for header in auth_headers:
|
||||
if header in fwd_headers:
|
||||
user = fwd_headers[header]
|
||||
logger.info(f"✅ Authelia user detected via forwarded headers - {header}: {user}")
|
||||
return user
|
||||
except:
|
||||
pass
|
||||
|
||||
# WORKAROUND: If Authelia session cookie exists and we're coming from auth.rune.pm,
|
||||
# assume user is authenticated (Authelia not forwarding headers properly)
|
||||
if ENABLE_PROXY and 'authelia_session' in request.cookies:
|
||||
referer = request.headers.get('Referer', '')
|
||||
if 'auth.rune.pm' in referer or request.headers.get('X-Forwarded-Proto') == 'https':
|
||||
# Valid Authelia session exists - assume authenticated
|
||||
# Use a generic identifier since we don't have the actual username
|
||||
pseudo_user = f"authelia_user_{request.cookies.get('authelia_session')[:8]}"
|
||||
logger.info(f"✅ Authelia authentication detected via session cookie (headers not forwarded)")
|
||||
logger.info(f" Using pseudo-user identifier: {pseudo_user}")
|
||||
logger.info(f" NOTE: Configure Authelia to forward Remote-User header for proper username")
|
||||
return pseudo_user
|
||||
|
||||
# Log when no Authelia user found (for debugging)
|
||||
if ENABLE_PROXY:
|
||||
logger.debug("⚠️ No Authelia headers found in request")
|
||||
logger.debug(f"Available headers: {list(request.headers.keys())}")
|
||||
|
||||
return None
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for routes"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Auto-login with Authelia (only when ENABLE_PROXY=true)
|
||||
if ENABLE_PROXY:
|
||||
authelia_user = get_authelia_user()
|
||||
|
||||
# If Authelia authenticated the user, auto-login
|
||||
if authelia_user:
|
||||
if not session.get('logged_in') or session.get('authelia_user') != authelia_user:
|
||||
logger.info(f"🔐 Auto-login via Authelia in API route: {authelia_user}")
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['authelia_user'] = authelia_user
|
||||
session['user_token'] = secrets.token_urlsafe(32)
|
||||
session['auth_method'] = 'authelia'
|
||||
session.modified = True
|
||||
|
||||
# Store additional info (check both Remote-* and X-Authelia-* headers)
|
||||
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
|
||||
request.headers.get('Remote-Email', ''))
|
||||
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
|
||||
request.headers.get('Remote-Name', ''))
|
||||
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
|
||||
request.headers.get('Remote-Groups', ''))
|
||||
|
||||
logger.info(f"✅ Auto-login in API route: {authelia_user}")
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Regular session check (when ENABLE_PROXY=false or no Authelia headers)
|
||||
if not session.get('logged_in'):
|
||||
logger.warning("Access denied: User not authenticated")
|
||||
if request.is_json:
|
||||
return jsonify({'status': 'error', 'message': 'Not authenticated', 'redirect': '/login'}), 401
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Login page"""
|
||||
"""Login page or JSON login endpoint"""
|
||||
|
||||
# Validate session mode matches current proxy setting
|
||||
if session.get('logged_in'):
|
||||
session_mode = session.get('auth_method', 'unknown')
|
||||
|
||||
# Clear session if mode mismatch
|
||||
if ENABLE_PROXY and session_mode == 'local':
|
||||
logger.info("⚠️ Session mode mismatch: Clearing local session (proxy mode enabled)")
|
||||
session.clear()
|
||||
elif not ENABLE_PROXY and session_mode == 'authelia':
|
||||
logger.info("⚠️ Session mode mismatch: Clearing authelia session (proxy mode disabled)")
|
||||
session.clear()
|
||||
|
||||
# Auto-login when ENABLE_PROXY=true and Authelia headers are present
|
||||
if ENABLE_PROXY:
|
||||
authelia_user = get_authelia_user()
|
||||
|
||||
if authelia_user:
|
||||
# User authenticated by Authelia - auto-login
|
||||
if not session.get('logged_in'):
|
||||
logger.info(f"🔐 Auto-login: User '{authelia_user}' authenticated by Authelia")
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['user_token'] = secrets.token_urlsafe(32)
|
||||
session['auth_method'] = 'authelia'
|
||||
session['authelia_user'] = authelia_user
|
||||
session.modified = True
|
||||
|
||||
# Get additional Authelia info (check both Remote-* and X-Authelia-* headers)
|
||||
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
|
||||
request.headers.get('Remote-Email', ''))
|
||||
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
|
||||
request.headers.get('Remote-Name', ''))
|
||||
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
|
||||
request.headers.get('Remote-Groups', ''))
|
||||
|
||||
logger.info(f"✅ Auto-login successful: {authelia_user} ({session.get('remote_email', 'no email')})")
|
||||
|
||||
# Already logged in via Authelia - redirect to main page
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
# ENABLE_PROXY=true but no Authelia headers found
|
||||
logger.warning("⚠️ ENABLE_PROXY=true but no Authelia headers detected!")
|
||||
logger.warning(" Make sure your reverse proxy forwards authentication headers")
|
||||
logger.warning(f" Available headers: {list(request.headers.keys())}")
|
||||
|
||||
# Handle form submission for local authentication (only when ENABLE_PROXY=false)
|
||||
if request.method == 'POST':
|
||||
password = request.json.get('password', '')
|
||||
logger.info("Login attempt with password (redacted)")
|
||||
|
||||
if malias_w.verify_password(password):
|
||||
logger.info("Login successful with local password")
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
return jsonify({'status': 'success', 'message': 'Login successful'})
|
||||
session['user_token'] = secrets.token_urlsafe(32)
|
||||
session['auth_method'] = 'local'
|
||||
session.modified = True
|
||||
|
||||
# Return JSON response
|
||||
response = jsonify({'status': 'success', 'message': 'Login successful'})
|
||||
return response
|
||||
else:
|
||||
logger.warning("Login failed: Invalid password")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid password'})
|
||||
|
||||
# Check if already logged in
|
||||
# If already logged in, redirect to index
|
||||
if session.get('logged_in'):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# Check cookies and client IP for troubleshooting
|
||||
if app.debug:
|
||||
logger.info(f"Cookies: {request.cookies}")
|
||||
logger.info(f"Client IP: {request.remote_addr}")
|
||||
logger.info(f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}")
|
||||
# Log all headers to see what's coming from Authelia
|
||||
logger.info(f"All headers: {dict(request.headers)}")
|
||||
|
||||
# Show login form
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
"""Logout"""
|
||||
session.pop('logged_in', None)
|
||||
return redirect(url_for('login'))
|
||||
# Get auth method before clearing session
|
||||
auth_method = session.get('auth_method')
|
||||
authelia_user = session.get('authelia_user')
|
||||
|
||||
# Clear local session
|
||||
session.clear()
|
||||
|
||||
# If user was authenticated via Authelia, redirect to app login (not Authelia logout)
|
||||
# This keeps the Authelia session active for other apps
|
||||
if ENABLE_PROXY and (auth_method == 'authelia' or authelia_user):
|
||||
logger.info(f"Logout for Authelia user - redirecting to app login page")
|
||||
# Just redirect back to login page - Authelia session stays active
|
||||
response = redirect(url_for('login'))
|
||||
response.set_cookie(app.config['SESSION_COOKIE_NAME'], '', expires=0)
|
||||
return response
|
||||
|
||||
# Default case: redirect to login page
|
||||
response = redirect(url_for('login'))
|
||||
|
||||
# Clear cookie by setting expired date
|
||||
response.set_cookie(app.config['SESSION_COOKIE_NAME'], '', expires=0)
|
||||
|
||||
return response
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Main page - requires login"""
|
||||
# Auto-login with Authelia (only when ENABLE_PROXY=true)
|
||||
if ENABLE_PROXY:
|
||||
authelia_user = get_authelia_user()
|
||||
|
||||
if authelia_user and not session.get('logged_in'):
|
||||
# Auto-login for users authenticated by Authelia
|
||||
logger.info(f"🔐 Auto-login via Authelia for user: {authelia_user}")
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['authelia_user'] = authelia_user
|
||||
session['user_token'] = secrets.token_urlsafe(32)
|
||||
session['auth_method'] = 'authelia'
|
||||
session.modified = True
|
||||
|
||||
# Store additional Authelia info (check both Remote-* and X-Authelia-* headers)
|
||||
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
|
||||
request.headers.get('Remote-Email', ''))
|
||||
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
|
||||
request.headers.get('Remote-Name', ''))
|
||||
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
|
||||
request.headers.get('Remote-Groups', ''))
|
||||
|
||||
logger.info(f"✅ Auto-login successful: {authelia_user} ({session.get('remote_email', 'no email')})")
|
||||
return render_template('index.html')
|
||||
|
||||
# Check if logged in
|
||||
if not session.get('logged_in'):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# Debug logging
|
||||
if app.debug and session.get('logged_in'):
|
||||
logger.info(f"User authenticated with method: {session.get('auth_method', 'unknown')}")
|
||||
|
||||
# Show main page
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/list_aliases', methods=['POST'])
|
||||
@@ -63,6 +362,7 @@ def list_aliases():
|
||||
result = malias_w.get_all_aliases(page=page, per_page=20)
|
||||
return jsonify({'status': 'success', 'data': result})
|
||||
except Exception as e:
|
||||
logger.exception("Error listing aliases")
|
||||
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
|
||||
|
||||
@app.route('/sync_aliases', methods=['POST'])
|
||||
@@ -74,6 +374,7 @@ def sync_aliases():
|
||||
result = f"Aliases synchronized successfully! Added {count} new aliases to local DB."
|
||||
return jsonify({'status': 'success', 'message': result})
|
||||
except Exception as e:
|
||||
logger.exception("Error syncing aliases")
|
||||
return jsonify({'status': 'error', 'message': f"Error syncing: {str(e)}"})
|
||||
|
||||
@app.route('/get_domains', methods=['POST'])
|
||||
@@ -86,6 +387,7 @@ def get_domains():
|
||||
result = f"Domains: {', '.join(domain_list)}"
|
||||
return jsonify({'status': 'success', 'message': result, 'domains': domain_list})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting domains")
|
||||
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
|
||||
|
||||
@app.route('/create_alias', methods=['POST'])
|
||||
@@ -104,6 +406,7 @@ def create_alias():
|
||||
result = f"Alias {alias} created successfully for {goto}"
|
||||
return jsonify({'status': 'success', 'message': result})
|
||||
except Exception as e:
|
||||
logger.exception("Error creating alias")
|
||||
return jsonify({'status': 'error', 'message': f"Error creating alias: {str(e)}"})
|
||||
|
||||
@app.route('/delete_alias', methods=['POST'])
|
||||
@@ -121,8 +424,64 @@ def delete_alias_route():
|
||||
result = f"Alias {alias} deleted successfully"
|
||||
return jsonify({'status': 'success', 'message': result})
|
||||
except Exception as e:
|
||||
logger.exception("Error deleting alias")
|
||||
return jsonify({'status': 'error', 'message': f"Error deleting alias: {str(e)}"})
|
||||
|
||||
@app.route('/delete_aliases_bulk', methods=['POST'])
|
||||
@login_required
|
||||
def delete_aliases_bulk():
|
||||
"""Delete multiple aliases at once"""
|
||||
try:
|
||||
data = request.json
|
||||
aliases = data.get('aliases', [])
|
||||
|
||||
if not aliases or not isinstance(aliases, list):
|
||||
return jsonify({'status': 'error', 'message': 'Aliases array is required'})
|
||||
|
||||
if len(aliases) == 0:
|
||||
return jsonify({'status': 'error', 'message': 'No aliases provided'})
|
||||
|
||||
# Delete multiple aliases
|
||||
result = malias_w.delete_multiple_aliases(aliases)
|
||||
|
||||
# Build response message
|
||||
success_count = result['success_count']
|
||||
failed_count = result['failed_count']
|
||||
total_count = success_count + failed_count
|
||||
|
||||
if failed_count == 0:
|
||||
message = f"{success_count} of {total_count} aliases deleted successfully"
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': message,
|
||||
'details': result
|
||||
})
|
||||
elif success_count == 0:
|
||||
# All failed
|
||||
failed_list = ', '.join([f['alias'] for f in result['failed_aliases'][:3]])
|
||||
if len(result['failed_aliases']) > 3:
|
||||
failed_list += f" and {len(result['failed_aliases']) - 3} more"
|
||||
message = f"Failed to delete aliases: {failed_list}"
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'details': result
|
||||
})
|
||||
else:
|
||||
# Partial success
|
||||
failed_list = ', '.join([f['alias'] for f in result['failed_aliases'][:3]])
|
||||
if len(result['failed_aliases']) > 3:
|
||||
failed_list += f" and {len(result['failed_aliases']) - 3} more"
|
||||
message = f"{success_count} of {total_count} aliases deleted successfully. Failed: {failed_list}"
|
||||
return jsonify({
|
||||
'status': 'partial',
|
||||
'message': message,
|
||||
'details': result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error deleting aliases bulk")
|
||||
return jsonify({'status': 'error', 'message': f"Error deleting aliases: {str(e)}"})
|
||||
|
||||
@app.route('/create_timed_alias', methods=['POST'])
|
||||
@login_required
|
||||
def create_timed_alias():
|
||||
@@ -139,6 +498,7 @@ def create_timed_alias():
|
||||
result = f"Timed alias created for {username} on domain {domain}"
|
||||
return jsonify({'status': 'success', 'message': result})
|
||||
except Exception as e:
|
||||
logger.exception("Error creating timed alias")
|
||||
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
|
||||
|
||||
@app.route('/search', methods=['POST'])
|
||||
@@ -161,6 +521,7 @@ def search():
|
||||
|
||||
return jsonify({'status': 'success', 'message': message, 'query': query})
|
||||
except Exception as e:
|
||||
logger.exception("Error searching aliases")
|
||||
return jsonify({'status': 'error', 'message': f"Search error: {str(e)}"})
|
||||
|
||||
@app.route('/config', methods=['GET', 'POST'])
|
||||
@@ -206,7 +567,230 @@ def change_password_route():
|
||||
malias_w.change_password(old_password, new_password)
|
||||
return jsonify({'status': 'success', 'message': 'Password changed successfully'})
|
||||
except Exception as e:
|
||||
logger.exception("Error changing password")
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
# Add a health check endpoint for proxies
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for proxies"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'authenticated': session.get('logged_in', False),
|
||||
'authelia_user': session.get('authelia_user', None),
|
||||
'auth_method': session.get('auth_method'),
|
||||
'version': '1.0.2',
|
||||
'cookies': {k: '***' for k in request.cookies.keys()}, # Only show cookie names
|
||||
'authelia_headers_present': get_authelia_user() is not None,
|
||||
'zoraxy_headers_present': 'X-Forwarded-Server' in request.headers
|
||||
})
|
||||
|
||||
# Add a debugging endpoint
|
||||
@app.route('/debug')
|
||||
def debug_info():
|
||||
"""Show debug information about the current request"""
|
||||
# Always allow this in production to help with troubleshooting
|
||||
debug_data = {
|
||||
'headers': dict(request.headers),
|
||||
'cookies': {k: '***' for k in request.cookies.keys()}, # Don't expose cookie values
|
||||
'session': {k: ('***' if k in ['user_token', 'csrf_token'] else v) for k, v in session.items()} if session else {},
|
||||
'remote_addr': request.remote_addr,
|
||||
'scheme': request.scheme,
|
||||
'host': request.host,
|
||||
'path': request.path,
|
||||
'is_secure': request.is_secure,
|
||||
'x_forwarded_for': request.headers.get('X-Forwarded-For'),
|
||||
'x_forwarded_proto': request.headers.get('X-Forwarded-Proto'),
|
||||
'x_forwarded_host': request.headers.get('X-Forwarded-Host'),
|
||||
'x_forwarded_prefix': request.headers.get('X-Forwarded-Prefix'),
|
||||
'remote_user': get_authelia_user(),
|
||||
}
|
||||
|
||||
# Additional server information
|
||||
debug_data['server_info'] = {
|
||||
'cookie_settings': {
|
||||
'SESSION_COOKIE_NAME': app.config['SESSION_COOKIE_NAME'],
|
||||
'SESSION_COOKIE_SECURE': app.config['SESSION_COOKIE_SECURE'],
|
||||
'SESSION_COOKIE_HTTPONLY': app.config['SESSION_COOKIE_HTTPONLY'],
|
||||
'SESSION_COOKIE_SAMESITE': app.config['SESSION_COOKIE_SAMESITE'],
|
||||
'SESSION_COOKIE_PATH': app.config['SESSION_COOKIE_PATH'],
|
||||
},
|
||||
'app_config': {
|
||||
'DEBUG': app.debug,
|
||||
'PREFERRED_URL_SCHEME': app.config['PREFERRED_URL_SCHEME'],
|
||||
'ENABLE_PROXY': ENABLE_PROXY
|
||||
}
|
||||
}
|
||||
|
||||
response = jsonify(debug_data)
|
||||
response.headers['Cache-Control'] = 'no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
# Dedicated endpoint for header diagnostics
|
||||
@app.route('/headers')
|
||||
def show_headers():
|
||||
"""Show all request headers - useful for debugging proxies"""
|
||||
# Log headers to help diagnose issues with Zoraxy/Authelia
|
||||
logger.info(f"Headers endpoint: All headers received: {dict(request.headers)}")
|
||||
return jsonify({
|
||||
'headers': dict(request.headers),
|
||||
'remote_addr': request.remote_addr,
|
||||
'authelia_user': get_authelia_user()
|
||||
})
|
||||
|
||||
# Self-test endpoint for cookies
|
||||
@app.route('/cookie-test')
|
||||
def cookie_test():
|
||||
"""Test cookie handling"""
|
||||
# Clear any existing cookie
|
||||
resp = make_response(jsonify({'status': 'ok', 'message': 'Cookie set test'}))
|
||||
|
||||
# Set a test cookie with the same attributes as session
|
||||
resp.set_cookie(
|
||||
'malias_test_cookie',
|
||||
'test-value',
|
||||
max_age=3600,
|
||||
secure=app.config['SESSION_COOKIE_SECURE'],
|
||||
httponly=app.config['SESSION_COOKIE_HTTPONLY'],
|
||||
samesite='None',
|
||||
path=app.config['SESSION_COOKIE_PATH']
|
||||
)
|
||||
|
||||
return resp
|
||||
|
||||
# Endpoint to check if test cookie is set
|
||||
@app.route('/cookie-check')
|
||||
def cookie_check():
|
||||
"""Check if test cookie was properly set"""
|
||||
test_cookie = request.cookies.get('malias_test_cookie')
|
||||
return jsonify({
|
||||
'test_cookie_present': test_cookie is not None,
|
||||
'test_cookie_value': test_cookie if test_cookie else None,
|
||||
'all_cookies': {k: '***' for k in request.cookies.keys()}
|
||||
})
|
||||
|
||||
# New endpoint to test Zoraxy auth configuration
|
||||
@app.route('/authelia-test')
|
||||
def authelia_test():
|
||||
"""Test if Authelia headers are correctly passed through Zoraxy"""
|
||||
all_headers = dict(request.headers)
|
||||
authelia_headers = {}
|
||||
|
||||
# Check for common Authelia-related headers
|
||||
auth_related_headers = [
|
||||
'Remote-User', 'X-Remote-User', 'Remote-Groups', 'X-Remote-Groups',
|
||||
'Remote-Name', 'X-Remote-Name', 'Remote-Email', 'X-Remote-Email',
|
||||
'X-Authelia-URL', 'X-Original-URL', 'X-Forwarded-Proto'
|
||||
]
|
||||
|
||||
for header in auth_related_headers:
|
||||
if header.lower() in [h.lower() for h in all_headers.keys()]:
|
||||
for actual_header in all_headers.keys():
|
||||
if header.lower() == actual_header.lower():
|
||||
authelia_headers[actual_header] = all_headers[actual_header]
|
||||
|
||||
# Check for auth cookies
|
||||
auth_cookies = {}
|
||||
for cookie_name in request.cookies:
|
||||
if 'auth' in cookie_name.lower():
|
||||
auth_cookies[cookie_name] = '***' # Hide actual value
|
||||
|
||||
return jsonify({
|
||||
'request_host': request.host,
|
||||
'authelia_user_detected': get_authelia_user() is not None,
|
||||
'authelia_user': get_authelia_user(),
|
||||
'authelia_headers': authelia_headers,
|
||||
'auth_cookies': auth_cookies,
|
||||
'all_headers_count': len(all_headers),
|
||||
'zoraxy_detected': any('zoraxy' in h.lower() for h in all_headers.keys()) or 'X-Forwarded-Server' in all_headers,
|
||||
'host_header': request.headers.get('Host'),
|
||||
'referer': request.headers.get('Referer'),
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5172)
|
||||
# Parse command-line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Mailcow Alias Manager - Web interface for managing mail aliases',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
python3 app.py Run in normal mode on port 5172
|
||||
python3 app.py --debug Run in debug mode with auto-reload
|
||||
python3 app.py --port 8080 Run on custom port 8080
|
||||
python3 app.py --debug --port 8080 Run in debug mode on port 8080
|
||||
python3 app.py --host 127.0.0.1 Bind to localhost only
|
||||
|
||||
Environment Variables (Docker):
|
||||
FLASK_DEBUG Set to 'True' for debug mode (default: False)
|
||||
FLASK_PORT Port to run on (default: 5172)
|
||||
FLASK_HOST Host to bind to (default: 0.0.0.0)
|
||||
FLASK_ENV Set to 'production' or 'development'
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Enable debug mode with auto-reload and detailed error messages'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Port to run on (default: 5172 or FLASK_PORT env var)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--host',
|
||||
type=str,
|
||||
default=None,
|
||||
help='Host to bind to (default: 0.0.0.0 or FLASK_HOST env var)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Priority: Command-line args > Environment variables > Defaults
|
||||
# Debug mode
|
||||
if args.debug:
|
||||
debug_mode = True
|
||||
else:
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'False').lower() in ('true', '1', 'yes')
|
||||
|
||||
# Set log level based on debug mode
|
||||
logger.setLevel(logging.DEBUG if debug_mode else logging.INFO)
|
||||
|
||||
# Port
|
||||
if args.port is not None:
|
||||
port = args.port
|
||||
else:
|
||||
port = int(os.getenv('FLASK_PORT', '5172'))
|
||||
|
||||
# Host
|
||||
if args.host is not None:
|
||||
host = args.host
|
||||
else:
|
||||
host = os.getenv('FLASK_HOST', '0.0.0.0')
|
||||
|
||||
# Print startup info
|
||||
print('=' * 60)
|
||||
print(' Mailcow Alias Manager - Starting...')
|
||||
print('=' * 60)
|
||||
print(f' Mode: {"DEBUG (Development)" if debug_mode else "NORMAL (Production)"}')
|
||||
print(f' Host: {host}')
|
||||
print(f' Port: {port}')
|
||||
print(f' URL: http://{host}:{port}')
|
||||
print('=' * 60)
|
||||
if debug_mode:
|
||||
print(' ⚠️ WARNING: Debug mode is enabled!')
|
||||
print(' - Auto-reload on code changes')
|
||||
print(' - Detailed error messages shown')
|
||||
print(' - NOT suitable for production use')
|
||||
print('=' * 60)
|
||||
print()
|
||||
|
||||
logger.info(f"Starting Mailcow Alias Manager - Debug: {debug_mode}, Host: {host}, Port: {port}")
|
||||
|
||||
app.run(debug=debug_mode, host=host, port=port)
|
||||
@@ -1,3 +1,20 @@
|
||||
# Mailcow Alias Manager - Docker Compose Configuration
|
||||
#
|
||||
# Production vs Development Mode:
|
||||
# --------------------------------
|
||||
# FLASK_ENV=production + FLASK_DEBUG=False → Uses Gunicorn (4 workers, production-ready)
|
||||
# FLASK_ENV=development or FLASK_DEBUG=True → Uses Flask dev server (auto-reload, debug info)
|
||||
#
|
||||
# Recommended Settings:
|
||||
# - Production: FLASK_ENV=production, FLASK_DEBUG=False (default)
|
||||
# - Development: FLASK_ENV=development, FLASK_DEBUG=True
|
||||
#
|
||||
# Reverse Proxy Support:
|
||||
# ----------------------
|
||||
# This application is configured to work behind reverse proxies (Nginx, Traefik, Zoraxy, Authelia, etc.)
|
||||
# The ProxyFix middleware automatically handles X-Forwarded-* headers for HTTPS detection
|
||||
# No additional configuration needed for most standard proxy setups
|
||||
|
||||
services:
|
||||
mailcow-alias-manager:
|
||||
image: gitlab.pm/rune/malias-web:latest
|
||||
@@ -9,3 +26,41 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Oslo
|
||||
|
||||
# Flask Configuration
|
||||
# Set to 'production' for Gunicorn (recommended) or 'development' for Flask dev server
|
||||
- FLASK_ENV=production
|
||||
|
||||
# Debug mode: False for production (recommended), True for development/troubleshooting
|
||||
- FLASK_DEBUG=False
|
||||
|
||||
# Port configuration (default: 5172)
|
||||
- FLASK_PORT=5172
|
||||
|
||||
# Host binding (default: 0.0.0.0 for Docker)
|
||||
- FLASK_HOST=0.0.0.0
|
||||
|
||||
# Secret key for sessions - CRITICAL for multi-worker Gunicorn!
|
||||
# All workers must use the SAME secret key to decode session cookies
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# IMPORATNT! The key below is just an example. GENERATE YOUR OWN KEY WITH COMMAND ABOVE!
|
||||
- SECRET_KEY=dca6920115b8bbc13c346c75d668a49849590c77d9baaf903296582f24ff816a
|
||||
|
||||
# Reverse Proxy Mode
|
||||
# ------------------
|
||||
# Set to 'true' when accessing through reverse proxy (Authelia, Zoraxy, Nginx, etc.)
|
||||
# Set to 'false' for direct IP:port access (e.g., http://192.168.1.100:5172)
|
||||
#
|
||||
# ENABLE_PROXY=true (Proxy Mode):
|
||||
# - ProxyFix middleware enabled (handles X-Forwarded-* headers)
|
||||
# - Secure cookies required (HTTPS only)
|
||||
# - SameSite=None (allows cross-origin authentication)
|
||||
# - Access via: https://alias.yourdomain.com
|
||||
#
|
||||
# ENABLE_PROXY=false (Direct Mode) - DEFAULT:
|
||||
# - ProxyFix middleware disabled
|
||||
# - Standard cookies (HTTP allowed)
|
||||
# - SameSite=Lax (standard browser behavior)
|
||||
# - Access via: http://your-server-ip:5172
|
||||
#
|
||||
- ENABLE_PROXY=false
|
||||
29
docker-entrypoint.sh
Executable file
29
docker-entrypoint.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get environment variables with defaults
|
||||
FLASK_DEBUG="${FLASK_DEBUG:-False}"
|
||||
FLASK_ENV="${FLASK_ENV:-production}"
|
||||
FLASK_PORT="${FLASK_PORT:-5172}"
|
||||
FLASK_HOST="${FLASK_HOST:-0.0.0.0}"
|
||||
|
||||
echo "Starting Mailcow Alias Manager..."
|
||||
echo "Environment: $FLASK_ENV"
|
||||
echo "Debug mode: $FLASK_DEBUG"
|
||||
echo "Port: $FLASK_PORT"
|
||||
|
||||
# Check if we should run in production mode
|
||||
if [ "$FLASK_ENV" = "production" ] && [ "$FLASK_DEBUG" != "True" ] && [ "$FLASK_DEBUG" != "true" ] && [ "$FLASK_DEBUG" != "1" ]; then
|
||||
echo "Starting with Gunicorn (production mode)..."
|
||||
exec gunicorn --bind $FLASK_HOST:$FLASK_PORT \
|
||||
--workers 4 \
|
||||
--threads 2 \
|
||||
--timeout 120 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
--log-level info \
|
||||
"app:app"
|
||||
else
|
||||
echo "Starting with Flask development server (debug mode)..."
|
||||
exec python app.py
|
||||
fi
|
||||
@@ -107,12 +107,12 @@ def get_config():
|
||||
return {'mailcow_server': '', 'mailcow_api_key': ''}
|
||||
|
||||
def search_aliases(query):
|
||||
"""Search for aliases in local database"""
|
||||
"""Search for aliases in local database (matches from start of alias address only)"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
search_term = '%' + query + '%'
|
||||
cursor.execute('SELECT alias, goto FROM aliases WHERE alias LIKE ? OR goto LIKE ?',
|
||||
(search_term, search_term))
|
||||
search_term = query + '%' # Match from start only
|
||||
cursor.execute('SELECT alias, goto FROM aliases WHERE alias LIKE ?',
|
||||
(search_term,))
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
return results
|
||||
@@ -384,3 +384,37 @@ def change_password(old_password, new_password):
|
||||
conn.close()
|
||||
|
||||
return True
|
||||
|
||||
def delete_multiple_aliases(alias_list):
|
||||
"""
|
||||
Delete multiple aliases in one operation
|
||||
|
||||
Args:
|
||||
alias_list: List of alias email addresses to delete
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- success_count: Number of successfully deleted aliases
|
||||
- failed_count: Number of failed deletions
|
||||
- failed_aliases: List of dicts with 'alias' and 'error' for each failure
|
||||
"""
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
failed_aliases = []
|
||||
|
||||
for alias in alias_list:
|
||||
try:
|
||||
delete_alias(alias)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
failed_aliases.append({
|
||||
'alias': alias,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
'success_count': success_count,
|
||||
'failed_count': failed_count,
|
||||
'failed_aliases': failed_aliases
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ python-dotenv==1.0.0
|
||||
httpx==0.27.0
|
||||
rich==13.7.0
|
||||
bcrypt==4.1.2
|
||||
gunicorn==21.2.0
|
||||
|
||||
103
reset_password.py
Executable file
103
reset_password.py
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Password Reset Script for Mailcow Alias Manager
|
||||
Usage: python3 reset_password.py [new_password]
|
||||
"""
|
||||
|
||||
import sys
|
||||
import sqlite3
|
||||
import bcrypt
|
||||
from pathlib import Path
|
||||
|
||||
# Database location
|
||||
project_dir = Path(__file__).parent
|
||||
filepath = project_dir.joinpath('data')
|
||||
database = filepath.joinpath('malias2.db')
|
||||
|
||||
def reset_password(new_password):
|
||||
"""Reset the admin password"""
|
||||
|
||||
if not database.exists():
|
||||
print(f"❌ Error: Database not found at {database}")
|
||||
print(" Make sure the application has been run at least once to create the database.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Hash the new password
|
||||
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt())
|
||||
|
||||
# Update database
|
||||
conn = sqlite3.connect(database)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if auth table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='auth'")
|
||||
if not cursor.fetchone():
|
||||
print("❌ Error: Auth table not found in database")
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
# Update password
|
||||
cursor.execute('UPDATE auth SET password_hash = ? WHERE id = 0', (password_hash.decode('utf-8'),))
|
||||
|
||||
# Verify update
|
||||
if cursor.rowcount == 0:
|
||||
print("❌ Error: No password record found. Creating new one...")
|
||||
cursor.execute('INSERT INTO auth VALUES (?, ?)', (0, password_hash.decode('utf-8')))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("=" * 60)
|
||||
print(" ✅ Password Reset Successful!")
|
||||
print("=" * 60)
|
||||
print(f" New password: {new_password}")
|
||||
print(f" Database: {database}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("You can now login with the new password.")
|
||||
print()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error resetting password: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" Mailcow Alias Manager - Password Reset")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Get new password
|
||||
if len(sys.argv) > 1:
|
||||
new_password = sys.argv[1]
|
||||
else:
|
||||
print("Enter new password (or press Ctrl+C to cancel):")
|
||||
new_password = input("New password: ").strip()
|
||||
|
||||
if not new_password:
|
||||
print("❌ Error: Password cannot be empty")
|
||||
return 1
|
||||
|
||||
if len(new_password) < 6:
|
||||
print("⚠️ Warning: Password is less than 6 characters")
|
||||
response = input("Continue anyway? (y/n): ").strip().lower()
|
||||
if response != 'y':
|
||||
print("Password reset cancelled.")
|
||||
return 0
|
||||
|
||||
print()
|
||||
if reset_password(new_password):
|
||||
return 0
|
||||
else:
|
||||
return 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nPassword reset cancelled.")
|
||||
sys.exit(0)
|
||||
@@ -454,6 +454,93 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Delete modal responsive styles */
|
||||
.delete-results-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.delete-results-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.delete-results-table tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.delete-results-table tr {
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
background-color: #252525;
|
||||
border-radius: 5px;
|
||||
padding: 50px 15px 15px 15px;
|
||||
border: 1px solid #404040;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.delete-results-table td {
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Checkbox in top-left corner */
|
||||
.delete-results-table td:first-child {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delete-checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Trash icon in top-right corner */
|
||||
.delete-results-table td:last-child {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* Alias content with labels */
|
||||
.delete-results-table td:nth-child(2)::before {
|
||||
content: "Alias: ";
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.delete-results-table td:nth-child(3)::before {
|
||||
content: "Goes to: ";
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
/* Bulk controls stack vertically */
|
||||
.bulk-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bulk-controls .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Delete confirm modal */
|
||||
.delete-confirm-list {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* Results warning */
|
||||
.results-warning {
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet Styles */
|
||||
@@ -470,6 +557,25 @@
|
||||
margin-left: 0;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* Delete table on tablets - keep table format but adjust sizing */
|
||||
.delete-results-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.delete-results-table th,
|
||||
.delete-results-table td {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.delete-checkbox {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Mobile Devices */
|
||||
@@ -486,6 +592,147 @@
|
||||
.result-section h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Delete modal adjustments for very small screens */
|
||||
.config-content {
|
||||
padding: 15px 12px;
|
||||
}
|
||||
|
||||
.delete-results-table tr {
|
||||
padding: 45px 12px 12px 12px;
|
||||
}
|
||||
|
||||
.delete-results-table td:first-child {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.delete-results-table td:last-child {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.delete-confirm-list {
|
||||
max-height: 150px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Delete Aliases Modal Specific Styles */
|
||||
.delete-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: #0066cc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
transition: transform 0.2s, filter 0.2s;
|
||||
display: inline-block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.delete-icon:hover {
|
||||
transform: scale(1.3);
|
||||
filter: brightness(1.4);
|
||||
}
|
||||
|
||||
.delete-icon:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.delete-results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.delete-results-table th {
|
||||
background-color: #1a1a1a;
|
||||
color: #ffffff;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #404040;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-results-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #404040;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.delete-table-row {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.delete-table-row:hover {
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.delete-table-row.selected {
|
||||
background-color: #1a3a5a !important;
|
||||
}
|
||||
|
||||
.delete-table-row.selected:hover {
|
||||
background-color: #244b6b !important;
|
||||
}
|
||||
|
||||
.results-warning {
|
||||
color: #ff9800;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #ff9800;
|
||||
}
|
||||
|
||||
.delete-confirm-list {
|
||||
margin: 15px 0;
|
||||
padding: 15px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 5px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.delete-confirm-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.delete-confirm-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #404040;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.delete-confirm-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.delete-confirm-list li::before {
|
||||
content: "• ";
|
||||
color: #f44336;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Column widths for delete table */
|
||||
.delete-results-table th:first-child,
|
||||
.delete-results-table td:first-child {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delete-results-table th:last-child,
|
||||
.delete-results-table td:last-child {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -501,7 +748,7 @@
|
||||
<button class="btn" onclick="executeAction('sync_aliases')">Sync Aliases</button>
|
||||
<button class="btn" onclick="executeAction('get_domains')">Show Domains</button>
|
||||
<button class="btn" onclick="openCreateAlias()">Create Alias</button>
|
||||
<button class="btn" onclick="openDeleteAlias()">Delete Alias</button>
|
||||
<button class="btn" onclick="openDeleteAliasesModal()">Delete Aliases</button>
|
||||
<button class="btn" onclick="openTimedAlias()">Create Timed Alias</button>
|
||||
<button class="btn btn-config" onclick="openConfig()">Configuration</button>
|
||||
</div>
|
||||
@@ -509,7 +756,7 @@
|
||||
<div class="search-section">
|
||||
<h2 style="margin-bottom: 15px; color: #ffffff;">Search Aliases</h2>
|
||||
<div class="search-container">
|
||||
<input type="text" class="search-input" id="searchInput" placeholder="Search for alias or destination email...">
|
||||
<input type="text" class="search-input" id="searchInput" placeholder="Start typing alias address (e.g., power...)">
|
||||
<button class="btn btn-primary" onclick="performSearch()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,17 +825,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Alias Modal -->
|
||||
<!-- Delete Aliases Modal -->
|
||||
<div class="config-modal" id="deleteAliasModal">
|
||||
<div class="config-content">
|
||||
<h2>Delete Alias</h2>
|
||||
<div class="config-content" style="max-width: 800px;">
|
||||
<h2>Delete Aliases</h2>
|
||||
|
||||
<!-- Search Section -->
|
||||
<div class="form-group">
|
||||
<label for="deleteAliasEmail">Alias Email to Delete</label>
|
||||
<input type="text" id="deleteAliasEmail" placeholder="alias@example.com">
|
||||
<label for="deleteSearchInput">Search for aliases to delete</label>
|
||||
<div class="search-container">
|
||||
<input type="text" class="search-input" id="deleteSearchInput" placeholder="Start typing alias address (e.g., power...)">
|
||||
<button class="btn btn-primary" onclick="searchAliasesForDeletion()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section (initially hidden) -->
|
||||
<div id="deleteResultsSection" style="display: none; margin-top: 20px;">
|
||||
<div id="deleteResultsInfo" style="margin-bottom: 10px; color: #4CAF50;"></div>
|
||||
<div id="deleteResultsTable" style="max-height: 400px; overflow-y: auto;"></div>
|
||||
|
||||
<!-- Bulk Selection Controls -->
|
||||
<div class="bulk-controls" style="display: flex; gap: 10px; margin: 15px 0; justify-content: center;">
|
||||
<button class="btn" onclick="selectAllDeleteAliases()">Select All</button>
|
||||
<button class="btn" onclick="deselectAllDeleteAliases()">Deselect All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="modal-buttons">
|
||||
<button class="btn" onclick="closeDeleteAlias()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="deleteAlias()">Delete</button>
|
||||
<button class="btn" onclick="closeDeleteAliasesModal()">Cancel</button>
|
||||
<button class="btn btn-primary" id="deleteSelectedBtn" onclick="confirmDeleteSelected()" disabled>
|
||||
Delete Selected (0)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<div class="config-modal" id="deleteConfirmModal">
|
||||
<div class="config-content">
|
||||
<h2 style="color: #f44336;">⚠️ Confirm Deletion</h2>
|
||||
<div id="deleteConfirmContent"></div>
|
||||
<p style="color: #f44336; margin-top: 15px; font-weight: bold;">This action cannot be undone.</p>
|
||||
<div class="modal-buttons">
|
||||
<button class="btn" onclick="closeDeleteConfirm()">Cancel</button>
|
||||
<button class="btn" style="background-color: #d32f2f; border-color: #d32f2f;" onclick="executeDeleteSelected()">
|
||||
Yes, Delete All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -672,6 +955,7 @@
|
||||
try {
|
||||
const response = await fetch('/list_aliases', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -700,6 +984,7 @@
|
||||
try {
|
||||
const response = await fetch(`/${actionName}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -725,6 +1010,7 @@
|
||||
try {
|
||||
const response = await fetch('/search', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -749,7 +1035,9 @@
|
||||
|
||||
async function openConfig() {
|
||||
try {
|
||||
const response = await fetch('/config');
|
||||
const response = await fetch('/config', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const config = await response.json();
|
||||
|
||||
document.getElementById('mailcowServer').value = config.mailcow_server || '';
|
||||
@@ -777,6 +1065,7 @@
|
||||
try {
|
||||
const response = await fetch('/config', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -811,6 +1100,7 @@
|
||||
try {
|
||||
const response = await fetch('/change_password', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -858,6 +1148,7 @@
|
||||
try {
|
||||
const response = await fetch('/create_alias', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -876,43 +1167,305 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Alias Modal Functions
|
||||
function openDeleteAlias() {
|
||||
// Delete Aliases Modal Functions
|
||||
let selectedAliases = new Set();
|
||||
let currentDeleteResults = [];
|
||||
|
||||
function openDeleteAliasesModal() {
|
||||
document.getElementById('deleteAliasModal').classList.add('active');
|
||||
document.getElementById('deleteSearchInput').value = '';
|
||||
document.getElementById('deleteResultsSection').style.display = 'none';
|
||||
selectedAliases.clear();
|
||||
currentDeleteResults = [];
|
||||
updateDeleteButtonText();
|
||||
}
|
||||
|
||||
function closeDeleteAlias() {
|
||||
function closeDeleteAliasesModal() {
|
||||
document.getElementById('deleteAliasModal').classList.remove('active');
|
||||
selectedAliases.clear();
|
||||
currentDeleteResults = [];
|
||||
}
|
||||
|
||||
async function deleteAlias() {
|
||||
const alias = document.getElementById('deleteAliasEmail').value;
|
||||
async function searchAliasesForDeletion() {
|
||||
const query = document.getElementById('deleteSearchInput').value;
|
||||
|
||||
if (!alias) {
|
||||
showResult('Alias email is required', false);
|
||||
if (!query.trim()) {
|
||||
showResult('Please enter a search query', false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/search', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ query: query })
|
||||
});
|
||||
|
||||
if (checkAuth(response)) return;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Parse search results
|
||||
const results = [];
|
||||
const lines = data.message.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('→')) {
|
||||
const parts = line.split('→').map(p => p.trim());
|
||||
if (parts.length === 2) {
|
||||
results.push({
|
||||
alias: parts[0],
|
||||
goto: parts[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to 100 results
|
||||
const limited = results.slice(0, 100);
|
||||
const hasMore = results.length > 100;
|
||||
|
||||
currentDeleteResults = limited;
|
||||
renderDeleteResults(limited, hasMore, results.length);
|
||||
} else {
|
||||
document.getElementById('deleteResultsSection').style.display = 'none';
|
||||
showResult(data.message, false);
|
||||
}
|
||||
} catch (error) {
|
||||
showResult(`Search error: ${error.message}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeleteResults(results, hasMore, totalCount) {
|
||||
if (results.length === 0) {
|
||||
document.getElementById('deleteResultsSection').style.display = 'none';
|
||||
showResult('No aliases found matching your search', false);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('deleteResultsSection').style.display = 'block';
|
||||
|
||||
// Info text
|
||||
let infoText = `Found ${results.length} alias(es)`;
|
||||
if (hasMore) {
|
||||
infoText += ` (showing first 100 of ${totalCount})`;
|
||||
}
|
||||
document.getElementById('deleteResultsInfo').innerHTML = infoText;
|
||||
|
||||
// Warning for too many results
|
||||
let warningHtml = '';
|
||||
if (hasMore) {
|
||||
warningHtml = `<div class="results-warning">⚠️ Showing first 100 of ${totalCount} results. Please refine your search for more specific results.</div>`;
|
||||
}
|
||||
|
||||
// Build table
|
||||
let html = warningHtml + `
|
||||
<table class="delete-results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Alias</th>
|
||||
<th>Goes To</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
const isSelected = selectedAliases.has(result.alias);
|
||||
html += `
|
||||
<tr class="delete-table-row ${isSelected ? 'selected' : ''}" id="delete-row-${i}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
class="delete-checkbox"
|
||||
id="checkbox-${i}"
|
||||
${isSelected ? 'checked' : ''}
|
||||
onchange="toggleAliasSelection('${escapeHtml(result.alias)}', ${i})">
|
||||
</td>
|
||||
<td>${escapeHtml(result.alias)}</td>
|
||||
<td>${escapeHtml(result.goto)}</td>
|
||||
<td>
|
||||
<span class="delete-icon" onclick="deleteSingleAliasQuick('${escapeHtml(result.alias)}', ${i})">🗑️</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('deleteResultsTable').innerHTML = html;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function toggleAliasSelection(alias, rowIndex) {
|
||||
const row = document.getElementById(`delete-row-${rowIndex}`);
|
||||
const checkbox = document.getElementById(`checkbox-${rowIndex}`);
|
||||
|
||||
if (selectedAliases.has(alias)) {
|
||||
selectedAliases.delete(alias);
|
||||
row.classList.remove('selected');
|
||||
} else {
|
||||
selectedAliases.add(alias);
|
||||
row.classList.add('selected');
|
||||
}
|
||||
|
||||
updateDeleteButtonText();
|
||||
}
|
||||
|
||||
function selectAllDeleteAliases() {
|
||||
selectedAliases.clear();
|
||||
for (const result of currentDeleteResults) {
|
||||
selectedAliases.add(result.alias);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
const checkboxes = document.querySelectorAll('.delete-checkbox');
|
||||
const rows = document.querySelectorAll('.delete-table-row');
|
||||
checkboxes.forEach(cb => cb.checked = true);
|
||||
rows.forEach(row => row.classList.add('selected'));
|
||||
|
||||
updateDeleteButtonText();
|
||||
}
|
||||
|
||||
function deselectAllDeleteAliases() {
|
||||
selectedAliases.clear();
|
||||
|
||||
// Update UI
|
||||
const checkboxes = document.querySelectorAll('.delete-checkbox');
|
||||
const rows = document.querySelectorAll('.delete-table-row');
|
||||
checkboxes.forEach(cb => cb.checked = false);
|
||||
rows.forEach(row => row.classList.remove('selected'));
|
||||
|
||||
updateDeleteButtonText();
|
||||
}
|
||||
|
||||
function updateDeleteButtonText() {
|
||||
const btn = document.getElementById('deleteSelectedBtn');
|
||||
const count = selectedAliases.size;
|
||||
btn.textContent = `Delete Selected (${count})`;
|
||||
btn.disabled = count === 0;
|
||||
}
|
||||
|
||||
function confirmDeleteSelected() {
|
||||
if (selectedAliases.size === 0) return;
|
||||
|
||||
const aliasArray = Array.from(selectedAliases);
|
||||
let confirmHtml = `
|
||||
<p>You are about to delete <strong>${aliasArray.length}</strong> alias(es):</p>
|
||||
<div class="delete-confirm-list">
|
||||
<ul>
|
||||
`;
|
||||
|
||||
for (const alias of aliasArray) {
|
||||
confirmHtml += `<li>${escapeHtml(alias)}</li>`;
|
||||
}
|
||||
|
||||
confirmHtml += '</ul></div>';
|
||||
|
||||
document.getElementById('deleteConfirmContent').innerHTML = confirmHtml;
|
||||
document.getElementById('deleteConfirmModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeDeleteConfirm() {
|
||||
document.getElementById('deleteConfirmModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function executeDeleteSelected() {
|
||||
const aliasArray = Array.from(selectedAliases);
|
||||
|
||||
if (aliasArray.length === 0) return;
|
||||
|
||||
closeDeleteConfirm();
|
||||
|
||||
try {
|
||||
const response = await fetch('/delete_aliases_bulk', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ aliases: aliasArray })
|
||||
});
|
||||
|
||||
if (checkAuth(response)) return;
|
||||
|
||||
const data = await response.json();
|
||||
showResult(data.message, data.status === 'success' || data.status === 'partial');
|
||||
|
||||
// Refresh search results
|
||||
if (data.status === 'success' || data.status === 'partial') {
|
||||
// Clear selection
|
||||
selectedAliases.clear();
|
||||
updateDeleteButtonText();
|
||||
|
||||
// Re-run search to show updated results
|
||||
await searchAliasesForDeletion();
|
||||
}
|
||||
} catch (error) {
|
||||
showResult(`Error deleting aliases: ${error.message}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSingleAliasQuick(alias, rowIndex) {
|
||||
if (!confirm(`Delete ${alias}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/delete_alias', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ alias: alias })
|
||||
});
|
||||
|
||||
if (checkAuth(response)) return;
|
||||
|
||||
const data = await response.json();
|
||||
showResult(data.message, data.status === 'success');
|
||||
|
||||
if (data.status === 'success') {
|
||||
document.getElementById('deleteAliasEmail').value = '';
|
||||
closeDeleteAlias();
|
||||
// Remove from selected if it was selected
|
||||
selectedAliases.delete(alias);
|
||||
|
||||
// Remove from current results
|
||||
currentDeleteResults = currentDeleteResults.filter(r => r.alias !== alias);
|
||||
|
||||
// Re-render table
|
||||
renderDeleteResults(currentDeleteResults, false, currentDeleteResults.length);
|
||||
|
||||
showResult(`Alias ${alias} deleted successfully`, true);
|
||||
} else {
|
||||
showResult(data.message, false);
|
||||
}
|
||||
} catch (error) {
|
||||
showResult(`Error deleting alias: ${error.message}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow search on Enter key in delete modal
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteSearchInput = document.getElementById('deleteSearchInput');
|
||||
if (deleteSearchInput) {
|
||||
deleteSearchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchAliasesForDeletion();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Timed Alias Modal Functions
|
||||
function openTimedAlias() {
|
||||
document.getElementById('timedAliasModal').classList.add('active');
|
||||
@@ -934,6 +1487,7 @@
|
||||
try {
|
||||
const response = await fetch('/create_timed_alias', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -960,7 +1514,10 @@
|
||||
if (e.target === this) closeCreateAlias();
|
||||
});
|
||||
document.getElementById('deleteAliasModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeDeleteAlias();
|
||||
if (e.target === this) closeDeleteAliasesModal();
|
||||
});
|
||||
document.getElementById('deleteConfirmModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeDeleteConfirm();
|
||||
});
|
||||
document.getElementById('timedAliasModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeTimedAlias();
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include', // CRITICAL: Include cookies in request
|
||||
body: JSON.stringify({ password: password })
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user