From b34b8b1d3fd2b3d49382b5da968c7c4dcafa5df5 Mon Sep 17 00:00:00 2001 From: DualStackAdmin <64967000+DualStackAdmin@users.noreply.github.com> Date: Thu, 9 Oct 2025 01:27:11 +0400 Subject: [PATCH] Update install.sh --- install.sh | 364 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 363 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 245075a..970eca1 100644 --- a/install.sh +++ b/install.sh @@ -82,4 +82,366 @@ from flask import Flask, request, redirect, url_for, render_template, send_from_ from celery import Celery from email import policy from email.parser import BytesParser -from datetime +from datetime import datetime, timedelta +from decouple import config +from functools import wraps + +# --- CONFIGURATION --- +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() + logout_uri = f"https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri={url_for('show_login_page', _external=True, _scheme='https')}" + return redirect(logout_uri) + +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/') +@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///') +@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///') +@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 + +# --- Template File Creation --- +echo "--> Creating HTML templates..." +mkdir -p templates/partials +# login.html +cat <<'EOF' > templates/login.html +Login - M365 Backup Tool
+EOF +# layout.html +cat <<'EOF' > templates/layout.html +{% block title %}{% endblock %} - M365 Backup Tool

{% block content %}{% endblock %}
+EOF +# index.html +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 +# partials/user_list.html +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 +# partials/user_list_error.html +cat <<'EOF' > templates/partials/user_list_error.html +
Error Loading Users

Could not load the user list.

Details: {{ error_message }}

+EOF +# backups.html +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 +# portal.html +cat <<'EOF' > templates/portal.html +{% extends 'layout.html' %}{% block title %}{{ user_email }} - Restore Portal{% endblock %}{% block content %} + +

Restore Portal for: {{ user_email }}


Select an email to preview...

+{% endblock %} +EOF +# tasks.html +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 + +# --- SSL Certificate Creation --- +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=AZ/ST=Baku/L=Baku/O=DevOps/OU=IT/CN=$IP_ADDR" + +# --- Service File Creation --- +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 + +# --- Nginx Configuration --- +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/ + +# --- Folder Permissions --- +chown -R $USERNAME:www-data "$PROJECT_DIR" +chmod -R 775 "$PROJECT_DIR" + +# --- Service Start and Verification --- +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 + +# --- Final Message --- +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 "============================================================"