# Media storage — MinIO integration How Winofy stores and serves product/store images. This matches the pattern used across the rest of the Winsoo ecosystem (`campaign`, `advertising`, `promotions`): presigned direct-to-MinIO uploads, object keys stored on the model, presigned reads minted per-response. Branch: `feature/minio-integration`. ## Why this shape The first pass at this integration used a Django `ImageField` + `django_minio_backend.MinioBackend` as the default storage, relying on normal DRF multipart uploads and a public-bucket direct URL. That worked, but didn't match how `campaign`/`advertising`/`promotions` do it, so it was reworked to the presign/object_key pattern below. See git history on this branch for both stages if you want the contrast. ## Architecture ``` 1. Client: POST /api/media/presign/ {"filename": "photo.jpg"} Server: generates object_key, returns a presigned MinIO PUT url (30 min expiry) 2. Client: PUT (goes straight to MinIO — the Django backend never sees the bytes) 3. Client: POST/PATCH the owning resource with the object_key e.g. PATCH /api/v1/seller/store/ {"logo": "uploads/.jpg"} 4. Any read of that resource returns both the raw object_key AND a freshly presigned GET url (1 hour expiry), e.g. "logo" + "logo_url" ``` Reads never trust a cached/stored URL — every serialization mints a new presigned GET URL via `apps/core/media.py::presigned_media_url()`. This is deliberate: it means bucket privacy can be tightened later (private bucket + short-lived signed links) without any API shape change, and callers can't accidentally cache a URL past its expiry without noticing (it 404s). ## Files added | File | Purpose | |---|---| | `utils/clients/minio_client.py` | Raw `Minio` SDK client (same shape as `advertising`/`campaign`'s equivalent) — used only for `presigned_put_object`/`presigned_get_object`. | | `apps/core/media.py` | `presigned_media_url(object_key, expires=1h)` — shared helper every serializer's `_url` calls. Swallows/logs errors and returns `None` rather than raising, so a MinIO blip doesn't break the whole response. | | `apps/core/serializers.py` | `MediaPresignInputSerializer` (`filename`), `MediaPresignOutputSerializer` (`upload_url`, `object_key`). | | `apps/core/views/media.py` | `MediaPresignView` — `POST /api/media/presign/`. | ## Files changed - `config/settings.py` — `django_minio_backend` added to `INSTALLED_APPS`; `STORAGES["default"]` points at `MinioBackend`; `MINIO_*` settings read from env (see below); `"Media"` tag added to `SPECTACULAR_SETTINGS`. - `apps/core/urls.py` — registers `media/presign/`. - `apps/catalog/models.py`, `apps/stores/models.py` — `icon`/`image`/`logo`/`cover_image` changed from `ImageField` to `CharField(max_length=500, null=True, blank=True)` storing a MinIO object key, not a Django-managed file. - `apps/catalog/serializers.py`, `apps/stores/serializers.py` — every serializer exposing one of those fields gained a `_url` `SerializerMethodField` (`icon_url`, `image_url`, `logo_url`, `cover_image_url`), typed via `@extend_schema_field(serializers.URLField(allow_null=True))` for clean OpenAPI output. - `docker-compose.yml` — added a local `minio` service (ports `9000`/`9001`, `miniodata` volume), wired as a `winofy` dependency. - `.env.example`, `CLAUDE.md` — documented the new `MINIO_*` vars. - `requirements.in`/`requirements.txt` — `django-minio-backend`, `minio`, `pycryptodome`, `argon2-cffi`(-bindings). - `FRONTEND_GUIDE.md` — §4.7 added (the upload flow), plus every stale "image field is a URL" / "no upload endpoint yet" reference updated across §4.5, Stores, Seller · Store, Seller · Products, §8, §9. ## Migrations - `apps/catalog/migrations/0002_alter_product_image_alter_productcategory_icon.py` - `apps/stores/migrations/0002_alter_store_cover_image_alter_store_logo_and_more.py` Both are pure `AlterField` (type/metadata change on an existing string column) — no data is dropped or moved. See **Existing data** below for what that means in practice. ## Settings / env vars ``` MINIO_ENDPOINT=localhost:9000 MINIO_USE_HTTPS=False MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_MEDIA_FILES_BUCKET=winofy-media ``` Defaults target the local `minio` container in `docker-compose.yml`. No shared bucket has been provisioned centrally yet (unlike `advertising`, which points at `drive.gooyal.com`) — `MINIO_MEDIA_FILES_BUCKET` just needs to match whatever this service's bucket ends up being called once one exists. **`MINIO_EXTERNAL_ENDPOINT` / `MINIO_EXTERNAL_ENDPOINT_USE_HTTPS`** (not set — intentionally omitted) exist in `django_minio_backend`'s `MinioBackend` for deployments where Django reaches MinIO through a different address than external clients do (e.g. an internal Docker/VPC hostname for Django vs. a public domain for browsers/apps) — they default to `MINIO_ENDPOINT`/`MINIO_USE_HTTPS` when unset, which is correct as long as Django and clients reach MinIO the same way (confirmed as the case here). Worth knowing if that topology ever changes: `utils/clients/minio_client.py` — the client actually used for every presign/read in this app — only reads `MINIO_ENDPOINT` and has no external-vs-internal split at all, since `STORAGES["default"]`'s `MinioBackend` (the only thing that respects `MINIO_EXTERNAL_ENDPOINT`) isn't exercised by any model field anymore now that `icon`/`image`/`logo`/`cover_image` are plain `CharField`s rather than `ImageField`s. Splitting internal/external addresses later would require adding that logic to `minio_client.py` directly, not just setting the env var. **First-run setup against a fresh MinIO instance:** the bucket doesn't exist until something creates it. Run: ```bash python manage.py initialize_buckets ``` (a `django_minio_backend` management command) before the presign flow will work — otherwise uploads fail with `S3Error: NoSuchBucket`. ## API reference ### `POST /api/media/presign/` Auth required (any authenticated user — permission checks for the *resource* still happen at the resource's own endpoint, e.g. `IsStoreOwner` on `/seller/store/`). ```http POST /api/media/presign/ Authorization: Bearer Content-Type: application/json {"filename": "photo.jpg"} ``` ```json 201 { "upload_url": "http://localhost:9000/winofy-media/uploads/.jpg?X-Amz-Algorithm=...&X-Amz-Expires=1800&...", "object_key": "uploads/.jpg" } ``` `upload_url` expires in 30 minutes. ### Direct upload ```http PUT ``` `200`, empty body. No auth header, no JSON — the signature in the URL is the auth. ### Attach to a resource ```http PATCH /api/v1/seller/store/ {"logo": "uploads/.jpg"} ``` Works identically via `POST` (create) or `PATCH`/`PUT` (update) on any endpoint that owns one of these fields — the object_key is just an ordinary writable string field, not special-cased per HTTP method. ### Reading Every serializer with an image field always includes both: ```json { "logo": "uploads/.jpg", "logo_url": "http://localhost:9000/winofy-media/uploads/.jpg?X-Amz-...&X-Amz-Expires=3600&...", "cover_image": null, "cover_image_url": null } ``` `_url` is re-signed fresh on every request (1 hour expiry) — don't cache it past that. **Fields covered:** `ProductCategory.icon`, `Product.image`, `StoreCategory.icon`, `Store.logo`, `Store.cover_image` (each with its `_url` sibling). ## Existing data — read before deploying to an environment with real uploads The migrations are schema-only, so no row is deleted and no string value is rewritten. But any value written **before** this branch (a path under the old local `FileSystemStorage`, e.g. `products/2026/03/01/photo.jpg`) is now meaningless: the app reads that same string as a MinIO object key and mints a presigned URL for it — which will `404` on fetch, because the actual file bytes were never copied into MinIO. `image_url`/`logo_url`/etc. will look populated in the response but not resolve. Nothing here deletes the original files either — they're still sitting wherever they were (local disk `media/` folder under the old storage), just no longer referenced by the app. **Before this branch reaches an environment with real uploaded images:** write a one-off backfill (upload each existing file into the `winofy-media` bucket under its existing relative path as the key, so the already-stored DB value keeps resolving without a bulk rewrite) or accept that sellers/customers will need to re-upload. Ask backend/infra whether the target environment has any real uploads before merging — this repo's local dev has none (no `media/` directory exists), so it's untested against real legacy data. ## Verification performed Ran against a live local MinIO container (not just unit tests): - `manage.py check` / `manage.py test` clean (pre-existing, unrelated failures in `apps/reviews` aside — see PR/branch notes). - `initialize_buckets` → bucket created with public read policy. - Presign → raw `PUT` upload → `object_key` stored on a real model instance → serializer-minted presigned GET URL → fetched and got the uploaded bytes back. - Hit the actual `POST /api/media/presign/` view through Django's test client: `201` authenticated, `401` anonymous. - `manage.py spectacular` schema generation: `/api/media/presign/` correctly tagged `Media`, request/response schemas resolve, `logo`/`cover_image`/`image`/`icon` show as writable strings (not binary) in both create and patch schemas, `_url` fields typed as nullable `string($uri)`.