Update install.sh

This commit is contained in:
DualStackAdmin 2025-10-09 01:27:11 +04:00 committed by GitHub
parent 41ecb26f61
commit b34b8b1d3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -82,4 +82,366 @@ from flask import Flask, request, redirect, url_for, render_template, send_from_
from celery import Celery from celery import Celery
from email import policy from email import policy
from email.parser import BytesParser 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/<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"<pre>{plain_body}</pre>"}
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
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login - M365 Backup Tool</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"></head><body><main class="container"><article style="margin-top: 5rem;"><h1 style="text-align: center;">M365 Backup Tool</h1><p style="text-align: center;">Please log in with your Microsoft account to continue.</p><div style="text-align: center; margin-top: 2rem;"><a href="/login" role="button">Login with Microsoft</a></div></article></main></body></html>
EOF
# layout.html
cat <<'EOF' > templates/layout.html
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{% block title %}{% endblock %} - M365 Backup Tool</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"><style>:root { --pico-font-size: 100%; } .htmx-indicator{opacity:0;transition:opacity 200ms ease-in;text-align:center;margin-top:1rem;} .htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}</style></head><body><main class="container"><nav><ul><li><strong>M365 Backup Tool</strong></li></ul><ul><li><a href="/">New Backup</a></li><li><a href="/backups">Existing Backups</a></li><li><a href="/tasks">Tasks</a></li>{% if session.user %}<li><a href="/logout" role="button" class="secondary outline">Logout ({{ session.user.name }})</a></li>{% endif %}</ul></nav><hr>{% block content %}{% endblock %}</main><script src="https://unpkg.com/htmx.org@1.9.10"></script></body></html>
EOF
# index.html
cat <<'EOF' > templates/index.html
{% extends 'layout.html' %}{% block title %}New Backup{% endblock %}{% block content %}<h2>Start New Backup</h2><form action="/backup" method="post"><div style="position: sticky; top: 0; background-color: var(--background-color); padding-top: 1rem; padding-bottom: 1rem; z-index: 10;"><div class="grid"><label for="date_option">Select Date Range<select id="date_option" name="date_option" onchange="toggleCustomDate()"><option value="all_time" selected>All Time</option><option value="last_3_months">Last 3 Months</option><option value="last_6_months">Last 6 Months</option><option value="last_1_year">Last 1 Year</option><option value="last_2_years">Last 2 Years</option><option value="custom_range">Custom Range</option></select></label><div></div></div><div id="custom_date_fields" style="display: none;" class="grid"><label for="start_date">Start Date<input type="date" id="start_date" name="start_date"></label><label for="end_date">End Date<input type="date" id="end_date" name="end_date"></label></div><button type="submit" style="margin-top: 1rem;">Backup Selected Users</button><hr></div><p>Select users to back up:</p><div hx-get="/get_users" hx-trigger="load" hx-indicator="#user-list-indicator"><p id="user-list-indicator" class="htmx-indicator"><progress></progress><br>Loading user list from Azure...</p></div></form><script>
function toggleCustomDate() {
const dateOption = document.getElementById('date_option'), customDateFields = document.getElementById('custom_date_fields');
if (dateOption.value === 'custom_range') { customDateFields.style.display = 'grid'; } else { customDateFields.style.display = 'none'; }
}
</script>{% endblock %}
EOF
# partials/user_list.html
cat <<'EOF' > templates/partials/user_list.html
<figure><table><thead><tr><th scope="col" style="width:50px;">Select</th><th scope="col">Display Name</th><th scope="col">Email</th></tr></thead><tbody>
{% for user in users %}<tr><td><input type="checkbox" name="emails" value="{{ user.userPrincipalName }}"></td><td>{{ user.displayName }}</td><td>{{ user.userPrincipalName }}</td></tr>
{% else %}<tr><td colspan="3">No users found or error loading users.</td></tr>{% endfor %}
</tbody></table></figure>
EOF
# partials/user_list_error.html
cat <<'EOF' > templates/partials/user_list_error.html
<article><h5 style="color:var(--pico-color-red);">Error Loading Users</h5><p>Could not load the user list.</p><p><small><strong>Details:</strong> {{ error_message }}</small></p></article>
EOF
# backups.html
cat <<'EOF' > templates/backups.html
{% extends 'layout.html' %}{% block title %}Existing Backups{% endblock %}{% block content %}<h2>Backed Up Users</h2><ul>{% for user in users %}<li><a href="/backups/{{ user }}">{{ user }}</a></li>{% else %}<li>No backups found yet.</li>{% endfor %}</ul>{% endblock %}
EOF
# portal.html
cat <<'EOF' > templates/portal.html
{% extends 'layout.html' %}{% block title %}{{ user_email }} - Restore Portal{% endblock %}{% block content %}
<style>.portal-grid{display:grid;grid-template-columns:250px 1fr 1fr;gap:1rem;height:80vh;}.folder-list,.email-list,.preview-pane{overflow-y:auto;height:100%;}.folder-list ul{list-style-type:none;padding:0;margin:0;}.folder-list a{display:block;padding:0.5rem 1rem;border-radius:0.25rem;text-decoration:none;color:var(--pico-contrast);}.folder-list a:hover{background-color:var(--pico-muted-background-color);}.folder-list a.active{background-color:var(--pico-primary);color:var(--pico-primary-inverse);}.email-list table tbody tr{cursor:pointer;}.preview-pane iframe{width:100%;height:100%;border:none;filter:blur(8px);transition:filter 0.3s ease-in-out;}.preview-pane:hover iframe{filter:blur(0);}</style>
<h2>Restore Portal for: {{ user_email }}</h2><hr><div class="portal-grid"><article class="folder-list"><ul>
{% for folder in folders %}<li><a href="{{ url_for('restore_portal', user_email=user_email, folder=folder) }}" class="{{ 'active' if folder == active_folder else '' }}">{{ folder }}</a></li>{% endfor %}
</ul></article><article class="email-list"><form method="get" action="{{ url_for('restore_portal', user_email=user_email) }}"><input type="search" name="q" placeholder="Search in {{ active_folder }}..." value="{{ search_query or '' }}" style="margin-bottom:0;"><input type="hidden" name="folder" value="{{ active_folder }}"></form><figure><table><tbody>
{% for email in emails %}<tr onclick="fetchEmail('{{ user_email }}', '{{ active_folder }}', '{{ email.filename }}')"><td><strong>{{ email.from }}</strong><br>{{ email.subject }}<br><small>{{ email.date }}</small></td></tr>
{% else %}<tr><td>No emails found in this folder.</td></tr>{% endfor %}
</tbody></table></figure></article><article class="preview-pane" id="preview-pane"><p>Select an email to preview...</p></article></div>
<script>
function fetchEmail(userEmail, folderName, fileName) {
const previewPane = document.getElementById('preview-pane');
previewPane.innerHTML = '<p>Loading...</p>';
fetch(`/preview/${userEmail}/${folderName}/${fileName}`).then(response=>response.json()).then(data=>{
if(data.error){previewPane.innerHTML=`<p>Error: ${data.error}</p>`;} else {
const iframe=document.createElement('iframe');previewPane.innerHTML='';previewPane.appendChild(iframe);
iframe.contentWindow.document.open();iframe.contentWindow.document.write(data.body);iframe.contentWindow.document.close();
}}).catch(error=>{previewPane.innerHTML=`<p>An error occurred: ${error}</p>`;});
}
</script>{% endblock %}
EOF
# tasks.html
cat <<'EOF' > templates/tasks.html
{% extends 'layout.html' %}{% block title %}Task Status{% endblock %}{% block content %}<h2>Task Status</h2><figure><table><thead><tr><th>Task ID</th><th>User</th><th>Status</th><th>Result</th></tr></thead><tbody>
{% for task in tasks %}<tr><td><small>{{ task.id }}</small></td><td>{{ task.email }}</td><td>{{ task.status }}</td><td><small>{{ task.result }}</small></td></tr>
{% else %}<tr><td colspan="4">No tasks have been started yet.</td></tr>{% endfor %}
</tbody></table></figure><a href="{{ url_for('list_tasks') }}" role="button" class="secondary outline">Refresh Page</a>{% 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 <<EOF > /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 <<EOF > /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 <<EOF > /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 "============================================================"