No description
Find a file
2025-10-09 02:07:08 +04:00
install.sh Update install.sh 2025-10-09 01:48:37 +04:00
LICENSE Initial commit 2025-10-09 01:25:38 +04:00
README.md Update README.md 2025-10-09 02:07:08 +04:00

#!/bin/bash

#=================================================================================

M365 Mailbox Backup Tool - Automatic Installation Script (v2.1 - Interactive IP)

#=================================================================================

--- Color Codes ---

GREEN='\033[0;32m' RED='\033[0;31m' NC='\033[0m' # No Color

if [ "$EUID" -ne 0 ]; then echo -e "${RED}Please run this script with 'sudo': sudo ./install.sh${NC}" exit 1 fi

echo "=== M365 Backup Tool Installation Started ==="

--- NEW: INTERACTIVE IP PROMPT ---

DETECTED_IP=$(hostname -I | awk '{print $1}') read -p "Enter the server IP address or press Enter to use the detected IP [$DETECTED_IP]: " USER_IP IP_ADDR=${USER_IP:-$DETECTED_IP}

------------------------------------

REDIRECT_URI="https://$IP_ADDR/get_token" echo "---------------------------------------------------------------------" echo "IMPORTANT: Please ensure you have set the following Redirect URI" echo "in your Azure AD App Registration under the 'Web' platform section:" echo "" echo "$REDIRECT_URI" echo "" echo "---------------------------------------------------------------------" read -p "Press Enter after you have added the Redirect URI in the Azure Portal..."

echo -n "Enter your Azure Tenant ID: " read TENANT_ID echo -n "Enter your Application (Client) ID: " read CLIENT_ID echo -n "Enter your Client Secret Value (will not be visible): " read -s CLIENT_SECRET echo "" echo -n "Enter comma-separated emails of allowed admins: " read ALLOWED_ADMINS echo "" echo "Credentials received."

--- System Dependency Installation ---

echo "--> Updating system and installing required packages..." apt-get update > /dev/null 2>&1 apt-get install -y nginx redis-server python3-pip python3-venv > /dev/null 2>&1

--- Project Directory and Virtual Environment ---

PROJECT_DIR="/opt/m365-backup-tool" USERNAME=${SUDO_USER:-$(whoami)}

echo "--> Creating project directory: $PROJECT_DIR" mkdir -p "$PROJECT_DIR" cd "$PROJECT_DIR"

echo "--> Creating Python virtual environment..." python3 -m venv venv

echo "--> Installing Python libraries..." source venv/bin/activate pip install Flask gunicorn "celery[redis]" msal requests python-decouple > /dev/null 2>&1 deactivate

--- Application File Creation ---

echo "--> Creating .env file for credentials..." cat < .env TENANT_ID=${TENANT_ID} CLIENT_ID=${CLIENT_ID} CLIENT_SECRET=${CLIENT_SECRET} FLASK_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(24))') ALLOWED_ADMINS=${ALLOWED_ADMINS} REDIRECT_URI=${REDIRECT_URI} EOF

echo "--> Creating application files and templates..."

app.py file

cat <<'EOF' > app.py import os, re, msal, requests, json, secrets from flask import Flask, request, redirect, url_for, render_template, send_from_directory, session from celery import Celery from email import policy from email.parser import BytesParser from datetime import datetime, timedelta from decouple import config from functools import wraps

TENANT_ID = config('TENANT_ID', default='') CLIENT_ID = config('CLIENT_ID', default='') CLIENT_SECRET = config('CLIENT_SECRET', default='') FLASK_SECRET_KEY = config('FLASK_SECRET_KEY') ALLOWED_ADMINS = [admin.strip().lower() for admin in config('ALLOWED_ADMINS', default='').split(',')] REDIRECT_URI = config('REDIRECT_URI') AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" SCOPE = ["User.Read"] BACKUP_DIR = os.path.join(os.getcwd(), "backup")

app = Flask(name) app.secret_key = FLASK_SECRET_KEY app.config.update(broker_url='redis://localhost:6379/0', result_backend='redis://localhost:6379/0') celery = Celery(app.name, broker=app.config['broker_url']) celery.conf.update(app.config)

msal_app = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET)

def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'user' not in session: return redirect(url_for('show_login_page')) if session['user'].get('email', '').lower() not in ALLOWED_ADMINS: session.clear(); return "Access Denied. You are not an authorized administrator.", 403 return f(*args, **kwargs) return decorated_function

@app.route('/login_page') def show_login_page(): return render_template('login.html')

@app.route('/login') def login(): session["flow"] = msal_app.initiate_auth_code_flow(SCOPE, redirect_uri=REDIRECT_URI) return redirect(session["flow"]["auth_uri"])

@app.route('/get_token') def get_token(): try: result = msal_app.acquire_token_by_auth_code_flow(session.get("flow", {}), request.args) if "error" in result: return f"Login Error: {result.get('error_description')}", 400 claims = result.get("id_token_claims", {}) user_email = claims.get("preferred_username") if not user_email or user_email.lower() not in ALLOWED_ADMINS: session.clear(); return "Access Denied. Your account is not in the list of authorized administrators.", 403 session["user"] = {"name": claims.get("name"), "email": user_email} except ValueError as e: return f"Error processing token: {str(e)}", 400 return redirect(url_for('index'))

@app.route('/logout') def logout(): session.clear() return redirect(f"https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri={url_for('show_login_page', _external=True, _scheme='https')}")

def sanitize_filename(filename): return re.sub(r'[\/*?:"<>|]', "", filename)

@celery.task def trigger_backup(user_email, date_option='all_time', start_date_str=None, end_date_str=None): app_for_backup = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET) result = app_for_backup.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"]) if "access_token" not in result: return f"Auth Error for background task: {result.get('error_description')}" access_token = result['access_token']; headers = {'Authorization': 'Bearer ' + access_token} user_backup_path = os.path.join(BACKUP_DIR, user_email) if not os.path.exists(user_backup_path): os.makedirs(user_backup_path) all_folders, folders_url = [], f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders?$top=100" while folders_url: response = requests.get(folders_url, headers=headers) if response.status_code != 200: return f"Error fetching mail folders: {response.json()}" data = response.json(); all_folders.extend(data.get('value', [])); folders_url = data.get('@odata.nextLink') total_message_count = 0 for folder in all_folders: folder_name, folder_id = folder.get('displayName'), folder.get('id') if not folder_name or not folder_id: continue local_folder_path = os.path.join(user_backup_path, sanitize_filename(folder_name)) if not os.path.exists(local_folder_path): os.makedirs(local_folder_path) filter_query = "" now = datetime.utcnow() if date_option == 'last_3_months': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=90)).isoformat()}Z" elif date_option == 'last_6_months': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=180)).isoformat()}Z" elif date_option == 'last_1_year': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=365)).isoformat()}Z" elif date_option == 'last_2_years': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=730)).isoformat()}Z" elif date_option == 'custom_range' and start_date_str and end_date_str: start_iso = datetime.fromisoformat(start_date_str).isoformat(); end_iso = (datetime.fromisoformat(end_date_str) + timedelta(days=1)).isoformat() filter_query = f"$filter=receivedDateTime ge {start_iso}Z and receivedDateTime lt {end_iso}Z" base_url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{folder_id}/messages" messages_url = f"{base_url}?$top=1000" if filter_query: messages_url = f"{base_url}?{filter_query}&$top=1000" message_count_in_folder = 0 while messages_url: msg_response = requests.get(messages_url, headers=headers) if msg_response.status_code != 200: break messages_data = msg_response.json(); messages = messages_data.get('value', []) for message in messages: message_id = message.get('id') if not message_id: continue mime_url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages/{message_id}/$value" mime_response = requests.get(mime_url, headers=headers) if mime_response.status_code == 200: with open(os.path.join(local_folder_path, f"{sanitize_filename(message_id)}.eml"), 'wb') as f: f.write(mime_response.content) message_count_in_folder += 1 messages_url = messages_data.get('@odata.nextLink') total_message_count += message_count_in_folder return f"Total {total_message_count} emails downloaded." @app.route('/') @login_required def index(): return render_template('index.html') @app.route('/get_users') @login_required def get_users(): app_for_users = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET) result = app_for_users.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"]) if "access_token" not in result: return render_template('partials/user_list_error.html', error_message="App Authentication Error.") headers = {'Authorization': 'Bearer ' + result['access_token']} users, users_url = [], "https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName" while users_url: response = requests.get(users_url, headers=headers) if response.status_code != 200: return render_template('partials/user_list_error.html', error_message="Error fetching users.") data = response.json(); users.extend(data.get('value', [])); users_url = data.get('@odata.nextLink') return render_template('partials/user_list.html', users=sorted(users, key=lambda u: u.get('displayName') or '')) @app.route('/backup', methods=['POST']) @login_required def backup(): emails = request.form.getlist('emails'); date_option = request.form.get('date_option'); start_date = request.form.get('start_date'); end_date = request.form.get('end_date') if 'tasks' not in session: session['tasks'] = [] for email in emails: task = trigger_backup.delay(email, date_option, start_date, end_date) session['tasks'].append({'id': task.id, 'email': email}) session.modified = True return redirect(url_for('list_tasks')) @app.route('/tasks') @login_required def list_tasks(): task_info = [] for task_meta in session.get('tasks', []): task = trigger_backup.AsyncResult(task_meta.get('id')) task_info.append({'id': task_meta.get('id'), 'email': task_meta.get('email'), 'status': task.state, 'result': task.result if task.state in ['SUCCESS', 'FAILURE'] else str(task.info)}) return render_template('tasks.html', tasks=reversed(task_info)) @app.route('/backups') @login_required def list_backups(): if not os.path.exists(BACKUP_DIR): os.makedirs(BACKUP_DIR) return render_template('backups.html', users=os.listdir(BACKUP_DIR)) @app.route('/backups/path:user_email') @login_required def restore_portal(user_email): user_dir = os.path.join(BACKUP_DIR, user_email) if not os.path.exists(user_dir): return "No backup found for this user.", 404 all_folders = [d for d in os.listdir(user_dir) if os.path.isdir(os.path.join(user_dir, d))] active_folder_name = request.args.get('folder') if not active_folder_name: active_folder_name = next((f for f in ['Inbox'] if f in all_folders), all_folders[0] if all_folders else None) search_query = request.args.get('q', '').lower() emails = [] if active_folder_name: selected_folder_path = os.path.join(user_dir, active_folder_name) if os.path.exists(selected_folder_path): parser = BytesParser(policy=policy.default) for filename in os.listdir(selected_folder_path): if filename.endswith(".eml"): with open(os.path.join(selected_folder_path, filename), 'rb') as f: msg = parser.parse(f) if search_query: content_to_search = str(msg.get_payload(decode=True)) if msg['subject']: content_to_search += msg['subject'] if search_query not in content_to_search.lower(): continue emails.append({'filename': filename, 'subject': msg['subject'], 'from': msg['from'], 'date': msg['date']}) return render_template('portal.html', user_email=user_email, folders=sorted(all_folders), emails=emails, active_folder=active_folder_name, search_query=search_query) @app.route('/download/path:user_email/path:folder_name/path:filename') @login_required def download_file(user_email, folder_name, filename): return send_from_directory(directory=os.path.join(BACKUP_DIR, user_email, folder_name), path=filename, as_attachment=True) @app.route('/preview/path:user_email/path:folder_name/path:filename') @login_required def preview_file(user_email, folder_name, filename): file_path = os.path.join(BACKUP_DIR, user_email, folder_name, filename) if not os.path.exists(file_path): return {"error": "File not found"}, 404 with open(file_path, 'rb') as f: msg = BytesParser(policy=policy.default).parse(f) html_body, plain_body = None, None if msg.is_multipart(): for part in msg.walk(): ct = part.get_content_type() if ct == 'text/html' and not html_body: html_body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='ignore') elif ct == 'text/plain' and not plain_body: plain_body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='ignore') else: plain_body = msg.get_payload(decode=True).decode(msg.get_content_charset() or 'utf-8', errors='ignore') if html_body: return {"body": html_body} elif plain_body: return {"body": f"

{plain_body}
"} else: return {"body": "Could not display content."} EOF

mkdir -p templates/partials cat <<'EOF' > templates/login.html <!doctype html><html lang="en" data-theme="dark"><head></head>

</html> EOF cat <<'EOF' > templates/layout.html <!doctype html><html lang="en" data-theme="dark"><head></head>
  • M365 Backup Tool

{% block content %}{% endblock %}</html> EOF cat <<'EOF' > templates/index.html {% extends 'layout.html' %}{% block title %}New Backup{% endblock %}{% block content %}

Start New Backup


Select users to back up:


Loading user list from Azure...

{% endblock %} EOF cat <<'EOF' > templates/partials/user_list.html

{% for user in users %} {% else %}{% endfor %}
SelectDisplay NameEmail
{{ user.displayName }}{{ user.userPrincipalName }}
No users found or error loading users.
EOF cat <<'EOF' > templates/partials/user_list_error.html
Error Loading Users

Could not load the user list.

Details: {{ error_message }}

EOF cat <<'EOF' > templates/backups.html {% extends 'layout.html' %}{% block title %}Existing Backups{% endblock %}{% block content %}

Backed Up Users

    {% for user in users %}
  • {{ user }}
  • {% else %}
  • No backups found yet.
  • {% endfor %}
{% endblock %} EOF cat <<'EOF' > templates/portal.html {% extends 'layout.html' %}{% block title %}{{ user_email }} - Restore Portal{% endblock %}{% block content %}

Restore Portal for: {{ user_email }}


    {% for folder in folders %}
  • {{ folder }}
  • {% endfor %}
{% for email in emails %} {% else %}{% endfor %}
{{ email.from }}
{{ email.subject }}
{{ email.date }}
No emails found in this folder.

Select an email to preview...

{% endblock %} EOF cat <<'EOF' > templates/tasks.html {% extends 'layout.html' %}{% block title %}Task Status{% endblock %}{% block content %}

Task Status

{% for task in tasks %} {% else %}{% endfor %}
Task IDUserStatusResult
{{ task.id }}{{ task.email }}{{ task.status }}{{ task.result }}
No tasks have been started yet.
Refresh Page{% endblock %} EOF

echo "--> Creating self-signed SSL certificate..." mkdir -p /etc/nginx/ssl openssl req -x509 -nodes -days 365 -newkey rsa:2048
-keyout /etc/nginx/ssl/m365-backup.key
-out /etc/nginx/ssl/m365-backup.crt
-subj "/C=XX/ST=Global/L=Global/O=Global/OU=IT/CN=$IP_ADDR"

echo "--> Creating systemd service files..." cat < /etc/systemd/system/m365-web.service [Unit] Description=Gunicorn for M365 Backup App After=network.target [Service] User=$USERNAME Group=www-data WorkingDirectory=$PROJECT_DIR ExecStart=$PROJECT_DIR/venv/bin/gunicorn --workers 3 --bind unix:$PROJECT_DIR/m365-backup.sock -m 007 app:app Restart=always [Install] WantedBy=multi-user.target EOF

cat < /etc/systemd/system/m365-worker.service [Unit] Description=Celery Worker for M365 Backup App After=network.target [Service] User=$USERNAME Group=www-data WorkingDirectory=$PROJECT_DIR ExecStart=$PROJECT_DIR/venv/bin/celery -A app.celery worker --loglevel=info Restart=always [Install] WantedBy=multi-user.target EOF

echo "--> Creating Nginx proxy configuration..." cat < /etc/nginx/sites-available/m365-backup server { listen 80; server_name $IP_ADDR; return 301 https://$server_name$request_uri; } server { listen 443 ssl; server_name $IP_ADDR; ssl_certificate /etc/nginx/ssl/m365-backup.crt; ssl_certificate_key /etc/nginx/ssl/m365-backup.key; ssl_protocols TLSv1.2 TLSv1.3; location / { include proxy_params; proxy_pass http://unix:$PROJECT_DIR/m365-backup.sock; } } EOF rm -f /etc/nginx/sites-enabled/default ln -sf /etc/nginx/sites-available/m365-backup /etc/nginx/sites-enabled/

chown -R $USERNAME:www-data "$PROJECT_DIR" chmod -R 775 "$PROJECT_DIR"

echo "--> Starting and enabling all services..." systemctl daemon-reload systemctl restart redis-server nginx systemctl start m365-web m365-worker systemctl enable redis-server nginx m365-web m365-worker

echo "--> Verifying service status..." sleep 5 FINAL_STATUS=0 if systemctl is-active --quiet m365-web.service; then echo -e "${GREEN} Web Server (Gunicorn) is running.${NC}" else echo -e "${RED} Web Server (Gunicorn) failed to start!${NC}"; FINAL_STATUS=1; fi if systemctl is-active --quiet m365-worker.service; then echo -e "${GREEN} Background Worker (Celery) is running.${NC}" else echo -e "${RED} Background Worker (Celery) failed to start!${NC}"; FINAL_STATUS=1; fi

echo "" echo "============================================================" if [ $FINAL_STATUS -eq 0 ]; then echo -e "${GREEN} INSTALLATION COMPLETED SUCCESSFULLY!${NC}" echo "" echo "You can access the application at: https://$IP_ADDR" else echo -e "${RED} INSTALLATION FAILED!${NC}" echo -e "${RED}Please check the error messages and service logs.${NC}" fi echo "============================================================"