From 1cfcf68f363e65a1b520d233b67b74eefffd6bf7 Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Mon, 15 Dec 2025 22:03:59 -0500 Subject: [PATCH] Images and Image Meta data, with Controllers and repos --- sql/ImageMetaManagement.sql | 56 ++++++++ .../battlbuilder/catalog/package-info.java | 12 ++ .../controllers/api/ImagesController.java | 55 ++++++++ .../controllers/api/SendEmailForm.tsx | 40 ++++++ .../battlbuilder/model/EmailRequest.java | 11 ++ .../battlbuilder/model/ImageBlob.java | 36 +++++ .../battlbuilder/model/ImageMeta.java | 67 ++++++++++ .../repos/ImageBlobRepository.java | 7 + .../repos/ImageMetaRepository.java | 8 ++ .../battlbuilder/security/UserPrincipal.java | 23 ++++ .../services/impl/ImageService.java | 124 ++++++++++++++++++ .../services/utils/impl/EmailServiceImpl.java | 34 +++-- 12 files changed, 461 insertions(+), 12 deletions(-) create mode 100644 sql/ImageMetaManagement.sql create mode 100644 src/main/java/group/goforward/battlbuilder/catalog/package-info.java create mode 100644 src/main/java/group/goforward/battlbuilder/controllers/api/ImagesController.java create mode 100644 src/main/java/group/goforward/battlbuilder/controllers/api/SendEmailForm.tsx create mode 100644 src/main/java/group/goforward/battlbuilder/model/ImageBlob.java create mode 100644 src/main/java/group/goforward/battlbuilder/model/ImageMeta.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/ImageBlobRepository.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/ImageMetaRepository.java create mode 100644 src/main/java/group/goforward/battlbuilder/security/UserPrincipal.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/impl/ImageService.java diff --git a/sql/ImageMetaManagement.sql b/sql/ImageMetaManagement.sql new file mode 100644 index 0000000..dd89c63 --- /dev/null +++ b/sql/ImageMetaManagement.sql @@ -0,0 +1,56 @@ +-- Auto-update updated_at on UPDATE (PostgreSQL) + +-- 1) Generic trigger function +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 2) Tables +CREATE TABLE IF NOT EXISTS image_meta ( + id BIGSERIAL PRIMARY KEY, + original_name TEXT, + content_type TEXT NOT NULL, + byte_size BIGINT NOT NULL CHECK (byte_size >= 0), + sha256 CHAR(64), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE TABLE IF NOT EXISTS image_blob ( + image_id BIGINT PRIMARY KEY, + data BYTEA NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + + CONSTRAINT fk_image_blob_meta + FOREIGN KEY (image_id) + REFERENCES image_meta(id) + ON DELETE CASCADE +); + +-- 3) Triggers (drop first to make script re-runnable) +DROP TRIGGER IF EXISTS trg_image_meta_set_updated_at ON image_meta; +CREATE TRIGGER trg_image_meta_set_updated_at +BEFORE UPDATE ON image_meta +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +DROP TRIGGER IF EXISTS trg_image_blob_set_updated_at ON image_blob; +CREATE TRIGGER trg_image_blob_set_updated_at +BEFORE UPDATE ON image_blob +FOR EACH ROW +EXECUTE FUNCTION set_updated_at(); + +-- 4) Helpful indexes +CREATE INDEX IF NOT EXISTS idx_image_meta_created_at ON image_meta(created_at); +CREATE INDEX IF NOT EXISTS idx_image_meta_sha256 ON image_meta(sha256); +CREATE INDEX IF NOT EXISTS idx_image_meta_deleted_at ON image_meta(deleted_at); +CREATE INDEX IF NOT EXISTS idx_image_blob_deleted_at ON image_blob(deleted_at); diff --git a/src/main/java/group/goforward/battlbuilder/catalog/package-info.java b/src/main/java/group/goforward/battlbuilder/catalog/package-info.java new file mode 100644 index 0000000..16b28cd --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/catalog/package-info.java @@ -0,0 +1,12 @@ + +/** + * Catalog package for the BattlBuilder application. + *

+ * This package contains classes responsible for platform resolution, + * rule compilation, and product context classification. + * + * @author Forward Group, LLC + * @version 1.0 + * @since 2025-12-10 + */ +package group.goforward.battlbuilder.catalog; diff --git a/src/main/java/group/goforward/battlbuilder/controllers/api/ImagesController.java b/src/main/java/group/goforward/battlbuilder/controllers/api/ImagesController.java new file mode 100644 index 0000000..976fa4c --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/api/ImagesController.java @@ -0,0 +1,55 @@ +package group.goforward.battlbuilder.controllers.api; + +import group.goforward.battlbuilder.model.ImageMeta; +import group.goforward.battlbuilder.security.UserPrincipal; +import group.goforward.battlbuilder.services.impl.ImageService; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.InputStream; +import java.time.Duration; + +@RestController +@RequestMapping("/api/images") +public class ImagesController { + + private final ImageService imageService; + + public ImagesController(ImageService imageService) { + this.imageService = imageService; + } + + @GetMapping("/{id}") + public ResponseEntity getImage(@PathVariable long id, + @AuthenticationPrincipal UserPrincipal user, + @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) { + + ImageMeta meta = imageService.requireAuthorizedMeta(id, user); // 403/404 (depending on exception mapping) + String etag = meta.getEtag(); + + if (ifNoneMatch != null && ifNoneMatch.contains(etag)) { + return ResponseEntity.status(304) + .eTag(etag) + .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePrivate()) + .build(); + } + + InputStream in = imageService.openImageStream(id); + StreamingResponseBody body = out -> { + try (in) { + in.transferTo(out); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(meta.getContentType())) + .contentLength(meta.getSize()) + .eTag(etag) + .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePrivate()) + .body(body); + } +} diff --git a/src/main/java/group/goforward/battlbuilder/controllers/api/SendEmailForm.tsx b/src/main/java/group/goforward/battlbuilder/controllers/api/SendEmailForm.tsx new file mode 100644 index 0000000..ff0b8a9 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/api/SendEmailForm.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { sendEmailAction, type ApiResponse, type EmailRequest } from "./actions/sendEmail"; + +export default function SendEmailForm(): JSX.Element { + const [result, setResult] = useState | { error: string } | null>(null); + + async function onSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + setResult(null); + + const form = new FormData(e.currentTarget); + + const recipient = String(form.get("recipient") ?? ""); + const subject = String(form.get("subject") ?? ""); + const body = String(form.get("body") ?? ""); + + try { + const data = await sendEmailAction({ recipient, subject, body }); + setResult(data); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setResult({ error: message }); + } + } + + return ( +

+ + +