a ton of fun happened, refactored alot

This commit is contained in:
2025-07-03 04:29:43 -05:00
parent 72e060d783
commit 1bbe6e2743
121 changed files with 2315 additions and 900 deletions

View File

@ -1,9 +1,6 @@
import os
import zipfile
import uuid
import io
import traceback
import tempfile
import logging
from datetime import datetime
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
@ -13,10 +10,11 @@ from flask import (
jsonify, abort
)
from flask_login import login_required, current_user
from PIL import Image, ExifTags
from PIL import Image, UnidentifiedImageError
from app import db
from .models import Media, ImageHeart, FeaturedImage
from .models import Media, ZipJob, ImageHeart, FeaturedImage
from .tasks import process_zip
bp = Blueprint(
"media",
@ -25,43 +23,91 @@ bp = Blueprint(
template_folder="templates"
)
# ─── Constants ──────────────────────────────────────────────────────────────
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
DOC_EXTS = {".pdf", ".txt", ".csv"}
ZIP_EXT = ".zip"
MAX_ZIP_FILES = 1000
MAX_IMAGE_PIXELS = 8000 * 8000 # ~64M pixels
# ─── Context Processor ──────────────────────────────────────────────────────
# ─── Context Processor ─────────────────────────────────────────────────────────
@bp.app_context_processor
def utility_processor():
def inject_helpers():
"""Expose generate_image_url in all media templates."""
return dict(generate_image_url=generate_image_url)
# ─── Helper Functions ───────────────────────────────────────────────────────
# ─── Helpers & Config ─────────────────────────────────────────────────────────
def allowed_file(filename):
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return ext in current_app.config.get(
"ALLOWED_EXTENSIONS",
{"png", "jpg", "jpeg", "gif", "webp"}
)
def get_upload_path(plugin: str, related_id: int):
def allowed_file(filename: str) -> bool:
"""
Return (absolute_dir, subdir) where uploads are stored:
<UPLOAD_FOLDER>/<plugin>/<related_id>/
Return True if the file extension is allowed.
"""
ext = os.path.splitext(filename)[1].lower()
allowed = current_app.config.get(
"ALLOWED_EXTENSIONS",
IMAGE_EXTS | DOC_EXTS | {ZIP_EXT}
)
return ext in allowed
def get_upload_path(plugin: str, related_id: int) -> (str, str):
"""
Build and return (absolute_dir, relative_subdir) under UPLOAD_FOLDER.
"""
base = current_app.config["UPLOAD_FOLDER"]
subdir = os.path.join(plugin, str(related_id))
abs_dir = os.path.join(base, subdir)
abs_dir = os.path.abspath(os.path.join(base, subdir))
if not abs_dir.startswith(os.path.abspath(base) + os.sep):
raise RuntimeError("Upload path escapes base directory")
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, subdir
def validate_image(path: str) -> bool:
"""
Verify image integrity and enforce pixel-size limit.
"""
try:
with Image.open(path) as img:
img.verify()
w, h = Image.open(path).size
return w * h <= MAX_IMAGE_PIXELS
except (UnidentifiedImageError, IOError):
return False
def _strip_exif(image: Image.Image) -> Image.Image:
def validate_pdf(path: str) -> bool:
"""
Quick header check for PDF files.
"""
try:
with open(path, "rb") as f:
return f.read(5) == b"%PDF-"
except IOError:
return False
def validate_text(path: str) -> bool:
"""
Ensure the file is valid UTF-8 text/CSV.
"""
try:
with open(path, "rb") as f:
f.read(1024).decode("utf-8")
return True
except Exception:
return False
def strip_exif(image: Image.Image) -> Image.Image:
"""
Rotate per EXIF orientation and strip metadata.
"""
try:
exif = image._getexif()
orient_key = next(
(k for k, v in ExifTags.TAGS.items() if v == "Orientation"),
None
)
if exif and orient_key in exif:
o = exif[orient_key]
if exif:
orientation_key = next(
(k for k, v in Image.ExifTags.TAGS.items()
if v == "Orientation"), None
)
o = exif.get(orientation_key)
if o == 3:
image = image.rotate(180, expand=True)
elif o == 6:
@ -70,8 +116,259 @@ def _strip_exif(image: Image.Image) -> Image.Image:
image = image.rotate(90, expand=True)
except Exception:
pass
return image
data = list(image.getdata())
clean = Image.new(image.mode, image.size)
clean.putdata(data)
return clean
def generate_image_url(media: Media):
"""
Given a Media instance, return its public URL or a placeholder.
"""
if media and media.file_url:
return url_for(
"media.serve_context_media",
context=media.plugin,
context_id=media.related_id,
filename=media.filename
)
# fallback placeholder
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
# ─── Core Media Routes ──────────────────────────────────────────────────────
@bp.route("/upload", methods=["POST"])
@login_required
def upload_media():
"""
Accept images, PDFs, text/CSV inline; enqueue ZIPs for async processing.
"""
uploaded: FileStorage = request.files.get("media")
if not uploaded or uploaded.filename == "":
flash("No file selected.", "warning")
return redirect(request.referrer or url_for("home"))
filename = secure_filename(uploaded.filename)
ext = os.path.splitext(filename)[1].lower()
if not allowed_file(filename):
flash("Unsupported file type.", "danger")
return redirect(request.referrer)
# Determine plugin & ID
plugin = request.form.get("plugin", "user")
related_id = int(request.form.get("related_id", current_user.id))
# Save location
abs_dir, subdir = get_upload_path(plugin, related_id)
save_path = os.path.join(abs_dir, filename)
uploaded.save(save_path)
# Validate & post-process
if ext in IMAGE_EXTS:
if not validate_image(save_path):
os.remove(save_path)
flash("Invalid or oversized image.", "danger")
return redirect(request.referrer)
with Image.open(save_path) as img:
clean = strip_exif(img)
clean.save(save_path)
elif ext == ".pdf":
if not validate_pdf(save_path):
os.remove(save_path)
flash("Invalid PDF.", "danger")
return redirect(request.referrer)
elif ext in {".txt", ".csv"}:
if not validate_text(save_path):
os.remove(save_path)
flash("Invalid text/CSV.", "danger")
return redirect(request.referrer)
elif ext == ZIP_EXT:
# Create and enqueue a ZipJob
job = ZipJob(user_id=current_user.id, filename=filename)
db.session.add(job)
db.session.commit()
process_zip.delay(job.id, save_path)
flash("ZIP received; processing in background.", "info")
return redirect(url_for("media.upload_status", job_id=job.id))
# Record small-file upload in DB
media = Media(
plugin = plugin,
related_id = related_id,
filename = filename,
file_url = f"{subdir}/{filename}",
uploader_id = current_user.id,
uploaded_at = datetime.utcnow()
)
db.session.add(media)
db.session.commit()
flash("File uploaded successfully.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/upload/<int:job_id>/status", methods=["GET"])
@login_required
def upload_status(job_id: int):
"""
Return JSON status for a background ZIP processing job.
"""
job = ZipJob.query.get_or_404(job_id)
if job.user_id != current_user.id:
abort(403)
return jsonify({
"job_id": job.id,
"status": job.status,
"error": job.error
})
@bp.route("/<context>/<int:context_id>/<filename>")
def serve_context_media(context: str, context_id: int, filename: str):
"""
Serve a file from UPLOAD_FOLDER/<plugin>/<id>/<filename>,
with pathtraversal guard and DB check.
"""
# Normalize plugin name
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin_name = context
elif context.endswith("s") and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
# Sanitize filename
safe_filename = secure_filename(filename)
if safe_filename != filename:
abort(404)
# Build and verify path
base_dir = current_app.config["UPLOAD_FOLDER"]
dir_path = os.path.join(base_dir, plugin_name, str(context_id))
full_path = os.path.abspath(os.path.join(dir_path, safe_filename))
if not full_path.startswith(os.path.abspath(base_dir) + os.sep):
abort(404)
# Confirm DB row
Media.query.filter_by(
plugin = plugin_name,
related_id = context_id,
filename = filename
).first_or_404()
return send_from_directory(dir_path, filename)
# ─── Utility Routes ─────────────────────────────────────────────────────────
@bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required
def toggle_heart(media_id: int):
"""
Toggle a “heart” (like) on an image for the current user.
"""
existing = ImageHeart.query.filter_by(
user_id = current_user.id,
media_id = media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify(status="unhearted")
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
db.session.add(heart)
db.session.commit()
return jsonify(status="hearted")
@bp.route("/featured/<context>/<int:context_id>/<int:media_id>", methods=["POST"])
@login_required
def set_featured_image(context: str, context_id: int, media_id: int):
"""
Mark a single image as featured for a given context.
"""
valid = {"plant", "growlog", "user", "vendor"}
if context in valid:
plugin_name = context
elif context.endswith("s") and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
media = Media.query.filter_by(
plugin = plugin_name,
related_id = context_id,
id = media_id
).first_or_404()
if media.uploader_id != current_user.id and current_user.role != "admin":
abort(403)
FeaturedImage.query.filter_by(
context = plugin_name,
context_id = context_id
).delete()
fi = FeaturedImage(
media_id = media.id,
context = plugin_name,
context_id = context_id,
is_featured = True
)
db.session.add(fi)
db.session.commit()
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required
def delete_media(media_id: int):
"""
Delete a media file and its DB record (softdelete by permission).
"""
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("home"))
# Remove file on disk
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.abspath(os.path.join(base, media.file_url))
try:
os.remove(full)
except OSError:
current_app.logger.error(f"Failed to delete file {full}")
# Remove DB record
db.session.delete(media)
db.session.commit()
flash("Media deleted.", "success")
return redirect(request.referrer or url_for("home"))
@bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required
def rotate_media(media_id: int):
"""
Rotate an image 90° and strip its EXIF metadata.
"""
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
abort(403)
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.abspath(os.path.join(base, media.file_url))
try:
with Image.open(full) as img:
rotated = img.rotate(-90, expand=True)
clean = strip_exif(rotated)
clean.save(full)
flash("Image rotated successfully.", "success")
except Exception as e:
current_app.logger.error(f"Rotation failed for {full}: {e}")
flash("Failed to rotate image.", "danger")
return redirect(request.referrer or url_for("home"))
# ─── Legacy Helpers for Other Plugins ───────────────────────────────────────
def _process_upload_file(
file: FileStorage,
@ -105,26 +402,29 @@ def _process_upload_file(
# 5) Build the Media row
now = datetime.utcnow()
media = Media(
plugin=plugin,
related_id=related_id,
filename=filename,
uploaded_at=now,
uploader_id=uploader_id,
caption=caption,
plant_id=plant_id,
growlog_id=growlog_id,
created_at=now,
file_url=file_url
plugin = plugin,
related_id = related_id,
filename = filename,
uploaded_at = now,
uploader_id = uploader_id,
caption = caption,
plant_id = plant_id,
growlog_id = growlog_id,
created_at = now,
file_url = file_url
)
return media
# ─── Exposed Utilities ─────────────────────────────────────────────────────────
def save_media_file(file, user_id, **ctx):
def save_media_file(file: FileStorage, user_id: int, **ctx) -> Media:
"""
Simple wrapper for other plugins to save an upload via the same logic.
"""
return _process_upload_file(file, user_id, **ctx)
def delete_media_file(media: Media):
"""
Remove a Media record and its file from disk, commit immediately.
"""
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, media.file_url))
if os.path.exists(full):
@ -132,256 +432,12 @@ def delete_media_file(media: Media):
db.session.delete(media)
db.session.commit()
def rotate_media_file(media: Media):
"""
Rotate a Media file 90° in place and commit metadata-only change.
"""
base = current_app.config["UPLOAD_FOLDER"]
full = os.path.normpath(os.path.join(base, media.file_url))
with Image.open(full) as img:
img.rotate(-90, expand=True).save(full)
db.session.commit()
def generate_image_url(media: Media):
"""
Given a Media instance (or None), return its public URL
under our new schema, or a placeholder if no media.
"""
if media and media.file_url:
# use singular context
return url_for(
"media.serve_context_media",
context=media.plugin,
context_id=media.related_id,
filename=media.filename
)
# fallback
w, h = current_app.config.get("STANDARD_IMG_SIZE", (300, 200))
return f"https://placehold.co/{w}x{h}"
@bp.route("/<context>/<int:context_id>/<filename>")
def serve_context_media(context, context_id, filename):
"""
Serve files saved under:
<UPLOAD_FOLDER>/<plugin>/<context_id>/<filename>
Accepts both singular and trailing-'s' contexts:
/media/plant/1/foo.jpg OR /media/plants/1/foo.jpg
"""
# — determine plugin name (always singular) —
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin = context
elif context.endswith("s") and context[:-1] in valid:
plugin = context[:-1]
else:
logging.debug(f"Invalid context '{context}' in URL")
abort(404)
# — build filesystem path —
base = current_app.config["UPLOAD_FOLDER"]
directory = os.path.join(base, plugin, str(context_id))
full_path = os.path.join(directory, filename)
# — Debug log what were about to do —
logging.debug(f"[serve_context_media] plugin={plugin!r}, "
f"context_id={context_id!r}, filename={filename!r}")
logging.debug(f"[serve_context_media] checking DB for media row…")
logging.debug(f"[serve_context_media] filesystem path = {full_path!r}, exists? {os.path.exists(full_path)}")
# — Check the DB row (but dont abort if missing) —
media = Media.query.filter_by(
plugin=plugin,
related_id=context_id,
filename=filename
).first()
if not media:
logging.warning(f"[serve_context_media] no Media DB row for "
f"{plugin}/{context_id}/{filename!r}, "
"will try serving from disk anyway")
# — If the file exists on disk, serve it — otherwise 404 —
if os.path.exists(full_path):
return send_from_directory(directory, filename)
logging.error(f"[serve_context_media] file not found on disk: {full_path!r}")
abort(404)
# ─── Legacy / Other Routes (you can leave these for backward compatibility) ────
@bp.route("/", methods=["GET"])
def media_index():
return redirect(url_for("core_ui.home"))
@bp.route("/<plugin>/<filename>")
def serve(plugin, filename):
# optional legacy support
m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404()
date_path = m.uploaded_at.strftime("%Y/%m/%d")
disk_dir = os.path.join(
current_app.config["UPLOAD_FOLDER"],
f"{plugin}s",
str(m.plant_id or m.growlog_id),
date_path
)
return send_from_directory(disk_dir, filename)
@bp.route("/<filename>")
def media_public(filename):
base = current_app.config["UPLOAD_FOLDER"]
m = Media.query.filter(Media.file_url.endswith(filename)).first_or_404()
full = os.path.normpath(os.path.join(base, m.file_url))
if not full.startswith(os.path.abspath(base)):
abort(404)
return send_from_directory(base, m.file_url)
@bp.route("/heart/<int:media_id>", methods=["POST"])
@login_required
def toggle_heart(media_id):
existing = ImageHeart.query.filter_by(
user_id=current_user.id, media_id=media_id
).first()
if existing:
db.session.delete(existing)
db.session.commit()
return jsonify({"status": "unhearted"})
heart = ImageHeart(user_id=current_user.id, media_id=media_id)
db.session.add(heart)
db.session.commit()
return jsonify({"status": "hearted"})
@bp.route("/add/<string:plant_uuid>", methods=["POST"])
@login_required
def add_media(plant_uuid):
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
file = request.files.get("file")
if not file or not allowed_file(file.filename):
flash("Invalid or missing file.", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
_process_upload_file(
file=file,
uploader_id=current_user.id,
plugin="plant",
related_id=plant.id
)
flash("Media uploaded successfully.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/<context>/<int:context_id>/<filename>")
def media_file(context, context_id, filename):
# your existing serve_context_media logic here
# (unchanged)
from flask import current_app, send_from_directory
import os
valid = {"user", "plant", "growlog", "vendor"}
if context in valid:
plugin = context
elif context.endswith("s") and context[:-1] in valid:
plugin = context[:-1]
else:
abort(404)
media = Media.query.filter_by(
plugin=plugin,
related_id=context_id,
filename=filename
).first_or_404()
base = current_app.config["UPLOAD_FOLDER"]
directory = os.path.join(base, plugin, str(context_id))
return send_from_directory(directory, filename)
@bp.route('/featured/<context>/<int:context_id>/<int:media_id>', methods=['POST'])
def set_featured_image(context, context_id, media_id):
"""
Singleselect “featured” toggle for any plugin (plants, grow_logs, etc).
"""
# normalize to singular plugin name (matches Media.plugin & FeaturedImage.context)
valid = {'plant', 'growlog', 'user', 'vendor'}
if context in valid:
plugin_name = context
elif context.endswith('s') and context[:-1] in valid:
plugin_name = context[:-1]
else:
abort(404)
# must own that media row
media = Media.query.filter_by(
plugin=plugin_name,
related_id=context_id,
id=media_id
).first_or_404()
# clear out any existing featured rows
FeaturedImage.query.filter_by(
context=plugin_name,
context_id=context_id
).delete()
# insert new featured row
fi = FeaturedImage(
media_id=media.id,
context=plugin_name,
context_id=context_id,
is_featured=True
)
db.session.add(fi)
db.session.commit()
# Redirect back with a flash instead of JSON
flash("Featured image updated.", "success")
return redirect(request.referrer or url_for("core_ui.home"))
@bp.route("/delete/<int:media_id>", methods=["POST"])
@login_required
def delete_media(media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to delete this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
delete_media_file(media)
flash("Media deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))
@bp.route("/bulk_delete/<string:plant_uuid>", methods=["POST"])
@login_required
def bulk_delete_media(plant_uuid):
plant = Plant.query.filter_by(uuid=plant_uuid).first_or_404()
media_ids = request.form.getlist("delete_ids")
deleted = 0
for mid in media_ids:
m = Media.query.filter_by(id=mid, plant_id=plant.id).first()
if m and (m.uploader_id == current_user.id or current_user.role == "admin"):
delete_media_file(m)
deleted += 1
flash(f"{deleted} image(s) deleted.", "success")
return redirect(request.referrer or url_for("plant.edit", uuid_val=plant_uuid))
@bp.route("/rotate/<int:media_id>", methods=["POST"])
@login_required
def rotate_media(media_id):
media = Media.query.get_or_404(media_id)
if media.uploader_id != current_user.id and current_user.role != "admin":
flash("Not authorized to rotate this media.", "danger")
return redirect(request.referrer or url_for("core_ui.home"))
try:
rotate_media_file(media)
flash("Image rotated successfully.", "success")
except Exception as e:
flash(f"Failed to rotate image: {e}", "danger")
return redirect(request.referrer or url_for("plant.edit", uuid_val=media.plant.uuid))