diff --git a/.gitignore b/.gitignore index ea4691b..424e42e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Flask/Migrations #migrations/ instance/ +mysql_data/ .env #.env.* @@ -21,6 +22,7 @@ logs/ # Uploads app/static/uploads/ +static/uploads/ # OS-generated files .DS_Store diff --git a/nip.zip b/nip.zip index db63835..a36fa77 100644 Binary files a/nip.zip and b/nip.zip differ diff --git a/plugins/media/models.py b/plugins/media/models.py index 91fdb1c..27e445d 100644 --- a/plugins/media/models.py +++ b/plugins/media/models.py @@ -57,20 +57,17 @@ class Media(db.Model): # If they passed plant_id or growlog_id in kwargs, pick one: if self.plant_id: - self.plugin = "plants" + self.plugin = "plant" self.related_id = self.plant_id elif self.growlog_id: - self.plugin = "grow_logs" + self.plugin = "growlog" self.related_id = self.growlog_id else: # fallback (you might choose to raise instead) self.plugin = kwargs.get("plugin", "") self.related_id = kwargs.get("related_id", 0) - # They must also supply `filename` before commit. - # Build `file_url` in the same format your property used to: - date_path = self.uploaded_at.strftime("%Y/%m/%d") - self.file_url = f"{self.plugin}/{self.related_id}/{date_path}/{self.filename}" + self.file_url = f"{self.plugin}/{self.related_id}/{self.filename}" @property def url(self): diff --git a/plugins/media/routes.py b/plugins/media/routes.py index cdd30dd..269331c 100644 --- a/plugins/media/routes.py +++ b/plugins/media/routes.py @@ -2,16 +2,20 @@ import os import uuid -from werkzeug.utils import secure_filename +import io +import traceback +import tempfile +import logging from datetime import datetime -from PIL import Image, ExifTags - +from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage from flask import ( Blueprint, request, redirect, url_for, flash, send_from_directory, current_app, jsonify, abort ) from flask_login import login_required, current_user +from PIL import Image, ExifTags from app import db from .models import Media, ImageHeart, FeaturedImage @@ -42,13 +46,11 @@ def allowed_file(filename): def get_upload_path(plugin: str, related_id: int): """ - Build and return (absolute_dir, subdir) where uploads are stored: - {UPLOAD_FOLDER}/{plugin}s/{related_id}/YYYY/MM/DD/ + Return (absolute_dir, subdir) where uploads are stored: + /// """ - now = datetime.utcnow() - date_path = f"{now.year}/{now.month:02}/{now.day:02}" - subdir = f"{plugin}s/{related_id}/{date_path}" base = current_app.config["UPLOAD_FOLDER"] + subdir = os.path.join(plugin, str(related_id)) abs_dir = os.path.join(base, subdir) os.makedirs(abs_dir, exist_ok=True) return abs_dir, subdir @@ -74,28 +76,37 @@ def _strip_exif(image: Image.Image) -> Image.Image: return image -def _process_upload_file(file, uploader_id, plugin='', related_id=0, plant_id=None, growlog_id=None, caption=None): - """Handles saving an uploaded file and creating the Media record.""" +def _process_upload_file( + file: FileStorage, + uploader_id: int, + plugin: str = '', + related_id: int = 0, + plant_id=None, + growlog_id=None, + caption=None +) -> Media: + """ + Handles saving an uploaded file under: + ///. + and returns a new Media object (not yet committed). + """ + # 1) Unique filename (uuid + original extension) + uid = str(uuid.uuid4()).replace('-', '') + _, ext = os.path.splitext(file.filename) + filename = f"{uid}{ext.lower()}" - # Generate a unique filename - now = datetime.utcnow() - unique_id = str(uuid.uuid4()).replace("-", "") - secure_name = secure_filename(file.filename) - filename = f"{unique_id}_{secure_name}" + # 2) Build the folder + abs_dir, subdir = get_upload_path(plugin, related_id) - # Construct the save path - storage_path = os.path.join( - current_app.config['UPLOAD_FOLDER'], - str(uploader_id), - now.strftime('%Y/%m/%d') - ) - os.makedirs(storage_path, exist_ok=True) - - full_path = os.path.join(storage_path, filename) + # 3) Save to disk + full_path = os.path.join(abs_dir, filename) file.save(full_path) - file_url = f"/{uploader_id}/{now.strftime('%Y/%m/%d')}/{filename}" + # 4) Record the relative URL fragment (for lookup) + file_url = f"{subdir}/{filename}" + # 5) Build the Media row + now = datetime.utcnow() media = Media( plugin=plugin, related_id=related_id, @@ -117,40 +128,91 @@ def save_media_file(file, user_id, **ctx): def delete_media_file(media: Media): - """ - Remove file from disk and delete DB record. - """ base = current_app.config["UPLOAD_FOLDER"] - path = os.path.join(base, media.file_url) - if os.path.exists(path): - os.remove(path) + full = os.path.normpath(os.path.join(base, media.file_url)) + if os.path.exists(full): + os.remove(full) db.session.delete(media) db.session.commit() def rotate_media_file(media: Media): - """ - Rotate the image on disk by -90° and save. - """ base = current_app.config["UPLOAD_FOLDER"] - path = os.path.join(base, media.file_url) - with Image.open(path) as img: - img.rotate(-90, expand=True).save(path) + 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 - or a placeholder if no media. + under our new schema, or a placeholder if no media. """ if media and media.file_url: - return url_for("media.media_file", filename=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}" -# ─── Routes ──────────────────────────────────────────────────────────────────── +@bp.route("///") +def serve_context_media(context, context_id, filename): + """ + Serve files saved under: + /// + 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 we’re 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 don’t 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")) @@ -158,11 +220,8 @@ def media_index(): @bp.route("//") def serve(plugin, filename): - """ - Stream uploaded media by plugin & filename, enforcing Media lookup. - """ + # optional legacy support m = Media.query.filter_by(file_url=f"{plugin}s/%/{filename}").first_or_404() - # reconstruct disk path date_path = m.uploaded_at.strftime("%Y/%m/%d") disk_dir = os.path.join( current_app.config["UPLOAD_FOLDER"],