refactor: 🎨 remove caching #48

Merged
jamie merged 15 commits from v2.0.0 into main 2026-05-23 21:04:45 +01:00
7 changed files with 2 additions and 483 deletions
Showing only changes of commit 22e17a8aec - Show all commits
+2 -2
View File
@@ -5,8 +5,8 @@ COPY . /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl mariadb-client-compat
RUN rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \
&& ./tailwindcss-linux-x64 -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify \
-3
View File
@@ -32,7 +32,6 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
docker run -d \
--name ipam \
-p 5000:5000 \
-v ./backups:/app/backups \
-e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=your_password \
@@ -61,8 +60,6 @@ services:
- SECRET_KEY=your_secret_key
- NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png
volumes:
- ./backups:/app/backups
```
## Configuration
-198
View File
@@ -8,7 +8,6 @@ import hashlib
import base64
import secrets
import logging
import subprocess
from io import StringIO, BytesIO
from datetime import datetime
from functools import wraps
@@ -20,7 +19,6 @@ import qrcode
import requests
import mysql.connector
from dotenv import load_dotenv
from werkzeug.utils import secure_filename
from flask import (
Flask, session, request, redirect, url_for, abort, jsonify,
render_template, send_from_directory, send_file, current_app,
@@ -2427,202 +2425,6 @@ def check_update():
logging.error(f"Unexpected error checking for updates: {e}")
return jsonify({'error': 'Failed to check for updates'}), 500
@app.route('/backup')
@permission_required('view_admin')
def backup():
"""Backup and restore page"""
from flask import current_app
# Ensure backups directory exists
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
os.makedirs(backups_dir, exist_ok=True)
# List available backups
backups = []
if os.path.exists(backups_dir):
for filename in os.listdir(backups_dir):
if filename.endswith('.sql'):
filepath = os.path.join(backups_dir, filename)
file_stat = os.stat(filepath)
backups.append({
'filename': filename,
'size': file_stat.st_size,
'created': datetime.fromtimestamp(file_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
})
# Sort by creation time (newest first)
backups.sort(key=lambda x: x['created'], reverse=True)
return render_with_user('backup.html', backups=backups)
@app.route('/backup/create', methods=['POST'])
@permission_required('view_admin')
def create_backup():
"""Create a database backup"""
from flask import current_app
try:
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
os.makedirs(backups_dir, exist_ok=True)
# Generate backup filename with timestamp
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'ipam_backup_{timestamp}.sql'
filepath = os.path.join(backups_dir, filename)
# Get database configuration
db_host = current_app.config['MYSQL_HOST']
db_user = current_app.config['MYSQL_USER']
db_password = current_app.config['MYSQL_PASSWORD']
db_name = current_app.config['MYSQL_DATABASE']
# Create backup using mysqldump
cmd = [
'mysqldump',
f'--host={db_host}',
f'--user={db_user}',
f'--password={db_password}',
'--skip-ssl',
'--single-transaction',
'--routines',
'--triggers',
db_name
]
with open(filepath, 'w') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
os.remove(filepath)
return jsonify({'error': f'Backup failed: {result.stderr}'}), 500
# Log the backup creation
with get_db_connection(current_app) as conn:
add_audit_log(session.get('user_id'), 'create_backup', f'Created backup: {filename}', conn=conn)
return jsonify({'success': True, 'filename': filename, 'message': 'Backup created successfully'})
except Exception as e:
logging.error(f"Error creating backup: {e}")
return jsonify({'error': f'Failed to create backup: {str(e)}'}), 500
@app.route('/backup/download/<filename>')
@permission_required('view_admin')
def download_backup(filename):
"""Download a backup file"""
from flask import current_app
# Security: ensure filename is safe
filename = secure_filename(filename)
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
filepath = os.path.join(backups_dir, filename)
if not os.path.exists(filepath) or not filename.endswith('.sql'):
abort(404)
return send_file(filepath, as_attachment=True, download_name=filename)
@app.route('/backup/restore', methods=['POST'])
@permission_required('view_admin')
def restore_backup():
"""Restore database from backup"""
from flask import current_app
try:
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
os.makedirs(backups_dir, exist_ok=True)
# Check if file was uploaded or if using existing file
if 'backup_file' in request.files:
# Handle file upload
file = request.files['backup_file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.endswith('.sql'):
return jsonify({'error': 'Invalid file type. Only .sql files are allowed'}), 400
# Save uploaded file
filename = secure_filename(file.filename)
filepath = os.path.join(backups_dir, filename)
file.save(filepath)
elif 'backup_filename' in request.form:
# Use existing backup file
filename = secure_filename(request.form['backup_filename'])
filepath = os.path.join(backups_dir, filename)
if not os.path.exists(filepath):
return jsonify({'error': 'Backup file not found'}), 404
else:
return jsonify({'error': 'No backup file specified'}), 400
# Get database configuration
db_host = current_app.config['MYSQL_HOST']
db_user = current_app.config['MYSQL_USER']
db_password = current_app.config['MYSQL_PASSWORD']
db_name = current_app.config['MYSQL_DATABASE']
# Close any existing database connections before restore
# This is important to avoid connection conflicts during restore
try:
# Try to close any open connections
pass
except:
pass
# Restore database using mysql command
cmd = [
'mysql',
f'--host={db_host}',
f'--user={db_user}',
f'--password={db_password}',
'--skip-ssl',
db_name
]
with open(filepath, 'r', encoding='utf-8') as f:
result = subprocess.run(cmd, stdin=f, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
return jsonify({'error': f'Restore failed: {result.stderr}'}), 500
# Log the restore
with get_db_connection(current_app) as conn:
add_audit_log(session.get('user_id'), 'restore_backup', f'Restored backup: {filename}', conn=conn)
return jsonify({'success': True, 'message': 'Database restored successfully'})
except Exception as e:
logging.error(f"Error restoring backup: {e}")
return jsonify({'error': f'Failed to restore backup: {str(e)}'}), 500
@app.route('/backup/delete/<filename>', methods=['POST'])
@permission_required('view_admin')
def delete_backup(filename):
"""Delete a backup file"""
from flask import current_app
# Security: ensure filename is safe
filename = secure_filename(filename)
backups_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'backups')
filepath = os.path.join(backups_dir, filename)
if not os.path.exists(filepath) or not filename.endswith('.sql'):
return jsonify({'error': 'Backup file not found'}), 404
try:
os.remove(filepath)
# Log the deletion
with get_db_connection(current_app) as conn:
add_audit_log(session.get('user_id'), 'delete_backup', f'Deleted backup: {filename}', conn=conn)
return jsonify({'success': True, 'message': 'Backup deleted successfully'})
except Exception as e:
logging.error(f"Error deleting backup: {e}")
return jsonify({'error': f'Failed to delete backup: {str(e)}'}), 500
@app.route('/get_available_ips')
@permission_required('view_device')
def get_available_ips():
-143
View File
@@ -1,143 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
const messageDiv = document.getElementById('message');
function showMessage(text, isError = false) {
messageDiv.textContent = text;
messageDiv.className = isError
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
messageDiv.classList.remove('hidden');
setTimeout(() => {
messageDiv.classList.add('hidden');
}, 5000);
}
// Create backup button
const createBackupBtn = document.getElementById('create-backup-btn');
if (createBackupBtn) {
createBackupBtn.addEventListener('click', function() {
createBackupBtn.disabled = true;
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
fetch('/backup/create', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(`Backup created successfully: ${data.filename}`);
setTimeout(() => window.location.reload(), 1500);
} else {
showMessage(data.error || 'Failed to create backup', true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
}
})
.catch(error => {
showMessage('Error creating backup: ' + error.message, true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
});
});
}
// Upload and restore form
const uploadRestoreForm = document.getElementById('upload-restore-form');
if (uploadRestoreForm) {
uploadRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
// Existing backup restore form
const existingRestoreForm = document.getElementById('existing-restore-form');
if (existingRestoreForm) {
existingRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
});
function deleteBackup(filename) {
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
return;
}
fetch(`/backup/delete/${filename}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Error: ' + (data.error || 'Failed to delete backup'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("message");function t(t,a=!1){e.textContent=t,e.className=a?"mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200":"mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200",e.classList.remove("hidden"),setTimeout(()=>{e.classList.add("hidden")},5e3)}let a=document.getElementById("create-backup-btn");a&&a.addEventListener("click",function(){a.disabled=!0,a.innerHTML='<i class="fas fa-spinner fa-spin"></i> Creating...',fetch("/backup/create",{method:"POST"}).then(e=>e.json()).then(e=>{e.success?(t(`Backup created successfully: ${e.filename}`),setTimeout(()=>window.location.reload(),1500)):(t(e.error||"Failed to create backup",!0),a.disabled=!1,a.innerHTML='<i class="fas fa-database"></i> <span>Create Backup</span>')}).catch(e=>{t("Error creating backup: "+e.message,!0),a.disabled=!1,a.innerHTML='<i class="fas fa-database"></i> <span>Create Backup</span>'})});let r=document.getElementById("upload-restore-form");r&&r.addEventListener("submit",function(e){if(e.preventDefault(),!confirm("WARNING: This will replace all current database data with the backup. Are you sure you want to continue?"))return;let a=new FormData(this),r=this.querySelector('button[type="submit"]'),n=r.innerHTML;r.disabled=!0,r.innerHTML='<i class="fas fa-spinner fa-spin"></i> Restoring...',fetch("/backup/restore",{method:"POST",body:a}).then(e=>e.json()).then(e=>{e.success?(t("Database restored successfully. Page will reload..."),setTimeout(()=>window.location.reload(),2e3)):(t(e.error||"Failed to restore backup",!0),r.disabled=!1,r.innerHTML=n)}).catch(e=>{t("Error restoring backup: "+e.message,!0),r.disabled=!1,r.innerHTML=n})});let n=document.getElementById("existing-restore-form");n&&n.addEventListener("submit",function(e){if(e.preventDefault(),!confirm("WARNING: This will replace all current database data with the backup. Are you sure you want to continue?"))return;let a=new FormData(this),r=this.querySelector('button[type="submit"]'),n=r.innerHTML;r.disabled=!0,r.innerHTML='<i class="fas fa-spinner fa-spin"></i> Restoring...',fetch("/backup/restore",{method:"POST",body:a}).then(e=>e.json()).then(e=>{e.success?(t("Database restored successfully. Page will reload..."),setTimeout(()=>window.location.reload(),2e3)):(t(e.error||"Failed to restore backup",!0),r.disabled=!1,r.innerHTML=n)}).catch(e=>{t("Error restoring backup: "+e.message,!0),r.disabled=!1,r.innerHTML=n})})});function deleteBackup(e){confirm(`Are you sure you want to delete backup "${e}"?`)&&fetch(`/backup/delete/${e}`,{method:"POST"}).then(e=>e.json()).then(e=>{e.success?window.location.reload():alert("Error: "+(e.error||"Failed to delete backup"))}).catch(e=>{alert("Error: "+e.message)})}
-10
View File
@@ -66,16 +66,6 @@
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Backup & Restore</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
</div>
<!-- Subnet Management Section -->
-126
View File
@@ -1,126 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
</div>
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
<!-- Create Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-database"></i>
<span>Create Backup</span>
</button>
</div>
<!-- Restore Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
<div class="space-y-4">
<!-- Upload Backup File -->
<div>
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
<span id="file-label" class="text-sm">Choose File</span>
</label>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-upload"></i> Upload & Restore
</button>
</form>
</div>
<!-- Or Select Existing Backup -->
{% if backups %}
<div>
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
<form id="existing-restore-form" class="flex gap-2">
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">Select a backup...</option>
{% for backup in backups %}
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-undo"></i> Restore
</button>
</form>
</div>
{% endif %}
</div>
</div>
<!-- Available Backups Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
{% if backups %}
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Filename</th>
<th class="px-4 py-2 text-left">Size</th>
<th class="px-4 py-2 text-left">Created</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2">{{ backup.filename }}</td>
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
<td class="px-4 py-2">{{ backup.created }}</td>
<td class="px-4 py-2 text-center">
<div class="flex gap-2 justify-center">
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
<i class="fas fa-download"></i>
</a>
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/backup.min.js"></script>
<script>
function updateFileLabel(input) {
const label = document.getElementById('file-label');
if (input.files && input.files[0]) {
label.textContent = input.files[0].name;
} else {
label.textContent = 'Choose File';
}
}
</script>
</body>
</html>