From 1c0186fb9de28310167490bda9f68a648b25e8c7 Mon Sep 17 00:00:00 2001 From: Zeecka Date: Fri, 9 Jan 2026 16:28:11 +0100 Subject: [PATCH 1/2] Removing image is working --- .env.example | 1 + .gitignore | 6 +- aperisolve/app.py | 96 ++++++++++++++++++++++++- aperisolve/config.py | 2 + aperisolve/static/css/aperisolve.css | 29 ++++++++ aperisolve/static/js/aperisolve.js | 102 +++++++++++++++++++++++++++ aperisolve/templates/index.html | 3 +- aperisolve/templates/result.html | 3 +- 8 files changed, 236 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index f12363f..3ce4220 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ MAX_PENDING_TIME=600 MAX_STORE_TIME=259200 CLEAR_AT_RESTART=1 SKIP_IHDR_FILL=0 +REMOVAL_MIN_AGE_SECONDS=300 # Flask configuration FLASK_DEBUG=0 diff --git a/.gitignore b/.gitignore index 61c8c3e..1587aef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -# Python .env .venv/ -__pycache__/ .mypy_cache/ +.vscode/ +__pycache__/ results/ -.vscode +removed_images/ diff --git a/aperisolve/app.py b/aperisolve/app.py index e5290f0..0bc4fba 100644 --- a/aperisolve/app.py +++ b/aperisolve/app.py @@ -6,6 +6,7 @@ import hashlib import json import os +import shutil import time from datetime import datetime, timezone from pathlib import Path @@ -20,7 +21,13 @@ from .utils.sentry import 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 @@ -217,6 +224,7 @@ def create_app() -> Flask: "last_submission_date": image.last_submission_date, "upload_count": image.upload_count, "passwords": passwords, + "removal_min_age_seconds": REMOVAL_MIN_AGE_SECONDS, } ) # type: ignore @@ -251,6 +259,92 @@ def create_app() -> Flask: return send_file(output_file, as_attachment=True) + @app.route("/remove/", 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("/image/") @app.route("/image//") def get_image(hash_val: Optional[str] = None, img_name: Optional[str] = None) -> Response: diff --git a/aperisolve/config.py b/aperisolve/config.py index 40d4c94..340ac24 100644 --- a/aperisolve/config.py +++ b/aperisolve/config.py @@ -10,6 +10,8 @@ IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"] WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize", "openstego", "pcrt"] 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_STORE_TIME = int(getenv("MAX_STORE_TIME", "259200")) # 3 days by default CLEAR_AT_RESTART = int(getenv("CLEAR_AT_RESTART", "0")) diff --git a/aperisolve/static/css/aperisolve.css b/aperisolve/static/css/aperisolve.css index a27cb42..d46d0c1 100644 --- a/aperisolve/static/css/aperisolve.css +++ b/aperisolve/static/css/aperisolve.css @@ -162,6 +162,35 @@ form { 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; +} + /* ======== Forms & Inputs ======== */ input, textarea, diff --git a/aperisolve/static/js/aperisolve.js b/aperisolve/static/js/aperisolve.js index 26eb3a4..5623d43 100644 --- a/aperisolve/static/js/aperisolve.js +++ b/aperisolve/static/js/aperisolve.js @@ -230,6 +230,108 @@ async function fetchImageInfo(submission_hash) { .join(", "); tableInfos.innerHTML += ` Common password(s):${passwordList}`; } + + // Fetch removal status and update removal UI + await updateRemovalUI(submission_hash, infoData); +} + +/** + * Image removal functionality + */ + +let removalTimerIntervals = {}; + +async function updateRemovalUI(submission_hash, infoData) { + console.log("Updating removal UI..."); + 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 = ` +

Remove Image

+

Available in ${remainingSeconds} seconds

+ + `; + + // 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 = ` +

Remove Image

+ +

+ `; + + 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 = ` Image successfully removed`; + removeBtn.style.display = "none"; + setTimeout(() => { document.location = '/'; }, 2000); + } else if (response.status === 403) { + if (data.error.includes("multiple IP")) { + statusEl.innerHTML = ` ${escapeHtml(data.error)}`; + } else { + statusEl.innerHTML = ` ${escapeHtml(data.error)}`; + } + removeBtn.disabled = false; + } else { + statusEl.innerHTML = ` Error: ${escapeHtml(data.error)}`; + removeBtn.disabled = false; + } + } catch (error) { + statusEl.innerHTML = ` Network error occurred`; + removeBtn.disabled = false; + console.error("Removal error:", error); + } + }); + } + } catch (error) { + console.error("Error updating removal UI:", error); + } } /** diff --git a/aperisolve/templates/index.html b/aperisolve/templates/index.html index 842086c..4624a83 100644 --- a/aperisolve/templates/index.html +++ b/aperisolve/templates/index.html @@ -34,5 +34,6 @@
+
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/aperisolve/templates/result.html b/aperisolve/templates/result.html index e1a7a32..546e21e 100644 --- a/aperisolve/templates/result.html +++ b/aperisolve/templates/result.html @@ -3,10 +3,11 @@
+
{% endblock %} {% block footer_content %} -{% endblock %} \ No newline at end of file +{% endblock %} From cee341c34c82b4f758873fbd0d3f00f93e1c585b Mon Sep 17 00:00:00 2001 From: Zeecka Date: Fri, 9 Jan 2026 17:35:46 +0100 Subject: [PATCH 2/2] Password removal working --- aperisolve/app.py | 55 +++++++++++ aperisolve/static/css/aperisolve.css | 17 ++++ aperisolve/static/js/aperisolve.js | 134 +++++++++++++++++++++++++-- 3 files changed, 200 insertions(+), 6 deletions(-) diff --git a/aperisolve/app.py b/aperisolve/app.py index 0bc4fba..92852ef 100644 --- a/aperisolve/app.py +++ b/aperisolve/app.py @@ -225,6 +225,7 @@ def create_app() -> Flask: "upload_count": image.upload_count, "passwords": passwords, "removal_min_age_seconds": REMOVAL_MIN_AGE_SECONDS, + "submission_date": submission.date, } ) # type: ignore @@ -345,6 +346,60 @@ def create_app() -> Flask: return jsonify({"message": "Image successfully removed"}), 200 + @app.route("/remove_password/", 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/") @app.route("/image//") def get_image(hash_val: Optional[str] = None, img_name: Optional[str] = None) -> Response: diff --git a/aperisolve/static/css/aperisolve.css b/aperisolve/static/css/aperisolve.css index d46d0c1..ccee6e4 100644 --- a/aperisolve/static/css/aperisolve.css +++ b/aperisolve/static/css/aperisolve.css @@ -191,6 +191,23 @@ form { 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 ======== */ input, textarea, diff --git a/aperisolve/static/js/aperisolve.js b/aperisolve/static/js/aperisolve.js index 5623d43..af973c7 100644 --- a/aperisolve/static/js/aperisolve.js +++ b/aperisolve/static/js/aperisolve.js @@ -224,25 +224,147 @@ async function fetchImageInfo(submission_hash) { infoData.size )}`; tableInfos.innerHTML += ` Upload count:${infoData.upload_count}`; - if (Array.isArray(infoData.passwords)) { - const passwordList = infoData.passwords - .map((pwd) => `${escapeHtml(pwd)}`) - .join(", "); - tableInfos.innerHTML += ` Common password(s):${passwordList}`; + if (Array.isArray(infoData.passwords) && infoData.passwords.length > 0) { + await displayPasswordsWithRemoval(tableInfos, infoData.passwords, submission_hash, infoData); } // 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 = ' 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 = `${escapeHtml(pwd)}`; + + // 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) { - console.log("Updating removal UI..."); const resultRemovalDiv = document.getElementById("result-removal"); resultRemovalDiv.innerHTML = "";