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 ARG VERSION=unknown
ENV VERSION=${VERSION} ENV VERSION=${VERSION}
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl mariadb-client-compat RUN apt-get update && apt-get install -y --no-install-recommends curl \
RUN rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \ RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x 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 \ && ./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 \ docker run -d \
--name ipam \ --name ipam \
-p 5000:5000 \ -p 5000:5000 \
-v ./backups:/app/backups \
-e MYSQL_HOST=10.10.2.27 \ -e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=ipam \ -e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=your_password \ -e MYSQL_PASSWORD=your_password \
@@ -61,8 +60,6 @@ services:
- SECRET_KEY=your_secret_key - SECRET_KEY=your_secret_key
- NAME=Your Organisation - NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png - LOGO_PNG=https://example.com/logo.png
volumes:
- ./backups:/app/backups
``` ```
## Configuration ## Configuration
-198
View File
@@ -8,7 +8,6 @@ import hashlib
import base64 import base64
import secrets import secrets
import logging import logging
import subprocess
from io import StringIO, BytesIO from io import StringIO, BytesIO
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
@@ -20,7 +19,6 @@ import qrcode
import requests import requests
import mysql.connector import mysql.connector
from dotenv import load_dotenv from dotenv import load_dotenv
from werkzeug.utils import secure_filename
from flask import ( from flask import (
Flask, session, request, redirect, url_for, abort, jsonify, Flask, session, request, redirect, url_for, abort, jsonify,
render_template, send_from_directory, send_file, current_app, 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}") logging.error(f"Unexpected error checking for updates: {e}")
return jsonify({'error': 'Failed to check for updates'}), 500 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') @app.route('/get_available_ips')
@permission_required('view_device') @permission_required('view_device')
def get_available_ips(): 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> <i class="fas fa-chevron-right text-gray-400"></i>
</a> </a>
{% endif %} {% 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> </div>
<!-- Subnet Management Section --> <!-- 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>