Merge pull request #153 from Zeecka/feature/image_deletion

Removing Images and Password - closing #149
This commit is contained in:
Zeecka
2026-01-09 17:37:59 +01:00
committed by GitHub
8 changed files with 435 additions and 11 deletions
+1
View File
@@ -4,6 +4,7 @@ MAX_PENDING_TIME=600
MAX_STORE_TIME=259200 MAX_STORE_TIME=259200
CLEAR_AT_RESTART=1 CLEAR_AT_RESTART=1
SKIP_IHDR_FILL=0 SKIP_IHDR_FILL=0
REMOVAL_MIN_AGE_SECONDS=300
# Flask configuration # Flask configuration
FLASK_DEBUG=0 FLASK_DEBUG=0
+3 -3
View File
@@ -1,7 +1,7 @@
# Python
.env .env
.venv/ .venv/
__pycache__/
.mypy_cache/ .mypy_cache/
.vscode/
__pycache__/
results/ results/
.vscode removed_images/
+150 -1
View File
@@ -6,6 +6,7 @@
import hashlib import hashlib
import json import json
import os import os
import shutil
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -20,7 +21,13 @@ from .utils.sentry import initialize_sentry
initialize_sentry() initialize_sentry()
from .config import IMAGE_EXTENSIONS, RESULT_FOLDER, WORKER_FILES from .config import (
IMAGE_EXTENSIONS,
REMOVAL_MIN_AGE_SECONDS,
REMOVED_IMAGES_FOLDER,
RESULT_FOLDER,
WORKER_FILES,
)
from .models import Image, Submission, UploadLog, cleanup_old_entries, db from .models import Image, Submission, UploadLog, cleanup_old_entries, db
@@ -217,6 +224,8 @@ def create_app() -> Flask:
"last_submission_date": image.last_submission_date, "last_submission_date": image.last_submission_date,
"upload_count": image.upload_count, "upload_count": image.upload_count,
"passwords": passwords, "passwords": passwords,
"removal_min_age_seconds": REMOVAL_MIN_AGE_SECONDS,
"submission_date": submission.date,
} }
) # type: ignore ) # type: ignore
@@ -251,6 +260,146 @@ def create_app() -> Flask:
return send_file(output_file, as_attachment=True) return send_file(output_file, as_attachment=True)
@app.route("/remove/<hash_val>", methods=["POST"])
def remove_image(hash_val: str) -> tuple[Response, int]:
"""Remove an image and associated results if criteria are met."""
submission = Submission.query.get_or_404(hash_val) # type: ignore
image = Image.query.get_or_404(submission.image_hash) # type: ignore
# Check if image is at least 5 minutes old
current_time = time.time()
age_seconds = current_time - submission.date
if age_seconds < REMOVAL_MIN_AGE_SECONDS:
return (
jsonify(
{
"error": "Image must be at least 5 minutes old. "
f"Current age: {int(age_seconds)}s"
}
),
403,
)
# Get all IPs that uploaded this image
upload_logs = UploadLog.query.filter_by(image_hash=image.hash).all()
unique_ips = set()
for log in upload_logs:
if log.ip_address:
unique_ips.add(log.ip_address)
# Check if only one IP has uploaded this image
if len(unique_ips) > 1:
return (
jsonify(
{
"error": "Image uploaded from multiple IP addresses. Removal is not "
"allowed.",
"ip_count": len(unique_ips),
}
),
403,
)
# Create archive directory if it doesn't exist
REMOVED_IMAGES_FOLDER.mkdir(parents=True, exist_ok=True)
# Archive the original image (copy to removed_images folder with metadata)
original_image_path = Path(image.file)
if original_image_path.exists():
dt = datetime.now(timezone.utc).isoformat()
archive_filename = f"{image.hash}_{submission.hash}_{dt}{original_image_path.suffix}"
archive_path = REMOVED_IMAGES_FOLDER / archive_filename
try:
shutil.copy2(original_image_path, archive_path)
except Exception as e:
sentry_sdk.capture_exception(e)
return jsonify({"error": "Failed to archive image"}), 500
# Delete the results folder
result_path = RESULT_FOLDER / str(image.hash) / str(submission.hash)
try:
if result_path.exists():
shutil.rmtree(result_path)
except Exception as e:
sentry_sdk.capture_exception(e)
return jsonify({"error": "Failed to delete results"}), 500
# Delete submission from database
try:
db.session.delete(submission) # pylint: disable=no-member
# If this was the last submission for this image, delete the image entry too
if len(image.submissions) <= 1:
# Also delete the original image file
if original_image_path.exists():
original_image_path.unlink()
db.session.delete(image) # pylint: disable=no-member
db.session.commit() # pylint: disable=no-member
except Exception as e:
db.session.rollback() # pylint: disable=no-member
sentry_sdk.capture_exception(e)
return jsonify({"error": "Failed to update database"}), 500
# Note: UploadLog entries are intentionally kept for audit purposes
return jsonify({"message": "Image successfully removed"}), 200
@app.route("/remove_password/<hash_val>", methods=["POST"])
def remove_password(hash_val: str) -> tuple[Response, int]:
"""Remove a password from a submission if criteria are met."""
submission = Submission.query.get_or_404(hash_val) # type: ignore
if not submission.password:
return jsonify({"error": "No password to remove"}), 400
# Check if submission is at least 5 minutes old
current_time = time.time()
age_seconds = current_time - submission.date
if age_seconds < REMOVAL_MIN_AGE_SECONDS:
return (
jsonify(
{
"error": f"Submission must be at least 5 minutes old. "
f"Current age: {int(age_seconds)}s"
}
),
403,
)
# Get all IPs that uploaded this specific submission (password combination)
upload_logs = UploadLog.query.filter_by(submission_hash=submission.hash).all()
unique_ips = set()
for log in upload_logs:
if log.ip_address:
unique_ips.add(log.ip_address)
# Check if only one IP has uploaded this submission
if len(unique_ips) > 1:
return (
jsonify(
{
"error": "Submission uploaded from multiple IP addresses. "
"Password removal is not allowed.",
"ip_count": len(unique_ips),
}
),
403,
)
# Remove the password
try:
submission.password = None
db.session.commit() # pylint: disable=no-member
except Exception as e:
db.session.rollback() # pylint: disable=no-member
sentry_sdk.capture_exception(e)
return jsonify({"error": "Failed to remove password"}), 500
return jsonify({"message": "Password successfully removed"}), 200
@app.route("/image/<img_name>") @app.route("/image/<img_name>")
@app.route("/image/<hash_val>/<img_name>") @app.route("/image/<hash_val>/<img_name>")
def get_image(hash_val: Optional[str] = None, img_name: Optional[str] = None) -> Response: def get_image(hash_val: Optional[str] = None, img_name: Optional[str] = None) -> Response:
+2
View File
@@ -10,6 +10,8 @@ IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"]
WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize", "openstego", "pcrt"] WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize", "openstego", "pcrt"]
RESULT_FOLDER = Path(__file__).parent.resolve() / "results" RESULT_FOLDER = Path(__file__).parent.resolve() / "results"
REMOVED_IMAGES_FOLDER = Path(__file__).parent.resolve() / "removed_images"
REMOVAL_MIN_AGE_SECONDS = int(getenv("REMOVAL_MIN_AGE_SECONDS", "300")) # 5 minutes
MAX_PENDING_TIME = int(getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default MAX_PENDING_TIME = int(getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default
MAX_STORE_TIME = int(getenv("MAX_STORE_TIME", "259200")) # 3 days by default MAX_STORE_TIME = int(getenv("MAX_STORE_TIME", "259200")) # 3 days by default
CLEAR_AT_RESTART = int(getenv("CLEAR_AT_RESTART", "0")) CLEAR_AT_RESTART = int(getenv("CLEAR_AT_RESTART", "0"))
+46
View File
@@ -162,6 +162,52 @@ form {
margin-top: 25px; margin-top: 25px;
} }
/* Removal card */
.removal-card {
padding: 20px;
margin-bottom: 20px;
border: 2px solid var(--theme-green);
background-color: rgba(159, 239, 0, 0.05);
}
.removal-card h3 {
margin-top: 0;
margin-bottom: 15px;
}
.removal-card p {
margin-bottom: 15px;
color: #e0e0e0;
}
.removal-card button:disabled {
background-color: #666;
cursor: not-allowed;
opacity: 0.6;
}
.removal-card #removal-status {
margin-top: 10px;
font-size: 0.95rem;
}
/* Password removal styling */
.password-item {
display: inline-block;
margin-right: 8px;
position: relative;
}
.password-delete-icon {
transition: color 0.2s, transform 0.2s;
vertical-align: middle;
}
.password-delete-icon:hover {
color: #ff0000 !important;
transform: scale(1.2);
}
/* ======== Forms & Inputs ======== */ /* ======== Forms & Inputs ======== */
input, input,
textarea, textarea,
+229 -5
View File
@@ -224,11 +224,235 @@ async function fetchImageInfo(submission_hash) {
infoData.size infoData.size
)}</td></tr>`; )}</td></tr>`;
tableInfos.innerHTML += `<tr><td><i class="fa fa-upload"></i> Upload count:</td><td>${infoData.upload_count}</td></tr>`; tableInfos.innerHTML += `<tr><td><i class="fa fa-upload"></i> Upload count:</td><td>${infoData.upload_count}</td></tr>`;
if (Array.isArray(infoData.passwords)) { if (Array.isArray(infoData.passwords) && infoData.passwords.length > 0) {
const passwordList = infoData.passwords await displayPasswordsWithRemoval(tableInfos, infoData.passwords, submission_hash, infoData);
.map((pwd) => `<code>${escapeHtml(pwd)}</code>`) }
.join(", ");
tableInfos.innerHTML += `<tr><td><i class="fa fa-key"></i> Common password(s):</td><td>${passwordList}</td></tr>`; // Fetch removal status and update removal UI
await updateRemovalUI(submission_hash, infoData);
}
async function displayPasswordsWithRemoval(tableInfos, passwords, submission_hash, infoData) {
const tr = document.createElement("tr");
const tdLabel = document.createElement("td");
const tdContent = document.createElement("td");
tdLabel.innerHTML = '<i class="fa fa-key"></i> Common password(s):';
tr.appendChild(tdLabel);
tr.appendChild(tdContent);
tableInfos.appendChild(tr);
// Clear existing password timer if any
if (passwordTimerIntervals[submission_hash]) {
clearInterval(passwordTimerIntervals[submission_hash]);
delete passwordTimerIntervals[submission_hash];
}
const uploadTime = infoData.submission_date * 1000;
const currentTime = new Date().getTime();
const ageSeconds = Math.floor((currentTime - uploadTime) / 1000);
const REMOVAL_MIN_AGE_SECONDS = infoData.removal_min_age_seconds || 300;
for (const pwd of passwords) {
const passwordSpan = document.createElement("span");
passwordSpan.className = "password-item";
passwordSpan.innerHTML = `<code>${escapeHtml(pwd)}</code>`;
// Check if this password can be removed
const canRemove = ageSeconds >= REMOVAL_MIN_AGE_SECONDS;
if (canRemove) {
const deleteIcon = document.createElement("i");
deleteIcon.className = "fas fa-times password-delete-icon";
deleteIcon.title = "Remove password";
deleteIcon.dataset.submissionHash = submission_hash;
deleteIcon.dataset.password = pwd;
deleteIcon.style.cursor = "pointer";
deleteIcon.style.marginLeft = "5px";
deleteIcon.style.color = "#ff4d4d";
deleteIcon.addEventListener("click", async function() {
await handlePasswordRemoval(submission_hash, deleteIcon);
});
passwordSpan.appendChild(deleteIcon);
}
tdContent.appendChild(passwordSpan);
tdContent.appendChild(document.createTextNode(" "));
}
// Set up timer to refresh when passwords become eligible for removal
if (ageSeconds < REMOVAL_MIN_AGE_SECONDS) {
const remainingSeconds = REMOVAL_MIN_AGE_SECONDS - ageSeconds;
passwordTimerIntervals[submission_hash] = setInterval(async () => {
const newUploadTime = infoData.submission_date * 1000;
const newCurrentTime = new Date().getTime();
const newAgeSeconds = Math.floor((newCurrentTime - newUploadTime) / 1000);
if (newAgeSeconds >= REMOVAL_MIN_AGE_SECONDS) {
// Time has elapsed, refresh the password display
clearInterval(passwordTimerIntervals[submission_hash]);
delete passwordTimerIntervals[submission_hash];
// Re-fetch info and refresh display
const infoResp = await fetch(`/infos/${submission_hash}`);
const newInfoData = await infoResp.json();
// Clear and rebuild password row
tr.remove();
await displayPasswordsWithRemoval(tableInfos, newInfoData.passwords, submission_hash, newInfoData);
}
}, Math.min(remainingSeconds * 1000, 1000)); // Check every second or at the exact time
}
}
async function checkPasswordRemovalEligibility(submission_hash, password, infoData) {
try {
// Check age constraint using submission date, not first_submission_date
const uploadTime = infoData.submission_date * 1000; // Convert to milliseconds
const currentTime = new Date().getTime();
const ageSeconds = Math.floor((currentTime - uploadTime) / 1000);
const REMOVAL_MIN_AGE_SECONDS = infoData.removal_min_age_seconds || 300;
if (ageSeconds < REMOVAL_MIN_AGE_SECONDS) {
return false;
}
return true;
} catch (error) {
console.error("Error checking password removal eligibility:", error);
return false;
}
}
async function handlePasswordRemoval(submission_hash, iconElement) {
try {
iconElement.style.pointerEvents = "none";
iconElement.style.opacity = "0.5";
const response = await fetch(`/remove_password/${submission_hash}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
// Remove the password item from display
const passwordSpan = iconElement.closest(".password-item");
if (passwordSpan) {
passwordSpan.style.transition = "opacity 0.3s";
passwordSpan.style.opacity = "0";
setTimeout(() => passwordSpan.remove(), 300);
}
} else {
// Silently fail - just re-enable the icon
iconElement.style.pointerEvents = "auto";
iconElement.style.opacity = "1";
}
} catch (error) {
console.error("Password removal error:", error);
iconElement.style.pointerEvents = "auto";
iconElement.style.opacity = "1";
}
}
/**
* Image removal functionality
*/
let removalTimerIntervals = {};
let passwordTimerIntervals = {};
async function updateRemovalUI(submission_hash, infoData) {
const resultRemovalDiv = document.getElementById("result-removal");
resultRemovalDiv.innerHTML = "";
try {
// Extract upload timestamp from first_submission_date
const uploadTime = new Date(infoData.first_submission_date).getTime();
const currentTime = new Date().getTime();
const ageSeconds = Math.floor((currentTime - uploadTime) / 1000);
const REMOVAL_MIN_AGE_SECONDS = infoData.removal_min_age_seconds || 300;
const removalContainer = document.createElement("div");
removalContainer.className = "card removal-card";
resultRemovalDiv.appendChild(removalContainer);
if (ageSeconds < REMOVAL_MIN_AGE_SECONDS) {
const remainingSeconds = REMOVAL_MIN_AGE_SECONDS - ageSeconds;
removalContainer.innerHTML = `
<h3><i class="fa fa-trash"></i> Remove Image</h3>
<p>Available in <span id="removal-countdown">${remainingSeconds}</span> seconds</p>
<button class="btn btn-primary" disabled id="remove-btn">Remove Image</button>
`;
// Clear existing interval if any
if (removalTimerIntervals[submission_hash]) {
clearInterval(removalTimerIntervals[submission_hash]);
}
// Start countdown timer
removalTimerIntervals[submission_hash] = setInterval(() => {
const countdownEl = document.getElementById("removal-countdown");
if (countdownEl) {
const current = parseInt(countdownEl.textContent);
if (current > 1) {
countdownEl.textContent = current - 1;
} else {
clearInterval(removalTimerIntervals[submission_hash]);
updateRemovalUI(submission_hash, infoData);
}
}
}, 1000);
} else {
// Check for multiple IPs - we'll do this by attempting removal
removalContainer.innerHTML = `
<h3><i class="fa fa-trash"></i> Remove Image</h3>
<button class="btn btn-primary" id="remove-btn">Remove Image</button>
<p id="removal-status"></p>
`;
const removeBtn = document.getElementById("remove-btn");
removeBtn.addEventListener("click", async () => {
removeBtn.disabled = true;
const statusEl = document.getElementById("removal-status");
try {
const response = await fetch(`/remove/${submission_hash}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (response.ok) {
statusEl.innerHTML = `<span class="text-success"><i class="fa fa-check"></i> Image successfully removed</span>`;
removeBtn.style.display = "none";
setTimeout(() => { document.location = '/'; }, 2000);
} else if (response.status === 403) {
if (data.error.includes("multiple IP")) {
statusEl.innerHTML = `<span class="text-danger"><i class="fa fa-warning"></i> ${escapeHtml(data.error)}</span>`;
} else {
statusEl.innerHTML = `<span class="text-danger"><i class="fa fa-warning"></i> ${escapeHtml(data.error)}</span>`;
}
removeBtn.disabled = false;
} else {
statusEl.innerHTML = `<span class="text-danger"><i class="fa fa-exclamation-circle"></i> Error: ${escapeHtml(data.error)}</span>`;
removeBtn.disabled = false;
}
} catch (error) {
statusEl.innerHTML = `<span class="text-danger"><i class="fa fa-exclamation-circle"></i> Network error occurred</span>`;
removeBtn.disabled = false;
console.error("Removal error:", error);
}
});
}
} catch (error) {
console.error("Error updating removal UI:", error);
} }
} }
+1
View File
@@ -34,5 +34,6 @@
<div id="result" class="mt-5"> <div id="result" class="mt-5">
<div id="result-infos" class="mb-3"></div> <div id="result-infos" class="mb-3"></div>
<div id="result-analyzers" class="mb-3"></div> <div id="result-analyzers" class="mb-3"></div>
<div id="result-removal" class="mb-3"></div>
</div> </div>
{% endblock %} {% endblock %}
+1
View File
@@ -3,6 +3,7 @@
<div id="result" class="mt-5"> <div id="result" class="mt-5">
<div id="result-infos" class="mb-3"></div> <div id="result-infos" class="mb-3"></div>
<div id="result-analyzers" class="mb-3"></div> <div id="result-analyzers" class="mb-3"></div>
<div id="result-removal" class="mb-3"></div>
</div> </div>
{% endblock %} {% endblock %}
{% block footer_content %} {% block footer_content %}