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 %}