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 (
+
+ );
+}
diff --git a/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java b/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java
index 8902618..bcf96c9 100644
--- a/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java
+++ b/src/main/java/group/goforward/battlbuilder/model/EmailRequest.java
@@ -50,10 +50,13 @@ public class EmailRequest {
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
if (status == null) {
status = "PENDING";
}
}
+ @Column(name = "updated_at", nullable = false, updatable = false)
+ private LocalDateTime updatedAt;
// Getters and Setters
public Long getId() {
@@ -119,4 +122,12 @@ public class EmailRequest {
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
}
diff --git a/src/main/java/group/goforward/battlbuilder/model/ImageBlob.java b/src/main/java/group/goforward/battlbuilder/model/ImageBlob.java
new file mode 100644
index 0000000..3e87a86
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/model/ImageBlob.java
@@ -0,0 +1,36 @@
+package group.goforward.battlbuilder.model;
+
+import jakarta.persistence.*;
+
+@Entity
+@Table(name = "image_blob")
+public class ImageBlob {
+
+ @Id
+ private Long id; // same as ImageMeta.id (1:1)
+
+ @Lob
+ @Basic(fetch = FetchType.LAZY)
+ @Column(name = "data", nullable = false)
+ private byte[] data;
+
+ protected ImageBlob() {}
+
+ public ImageBlob(Long id, byte[] data) {
+ this.id = id;
+ this.data = data;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+
+ public void setData(byte[] data) {
+ this.data = data;
+ }
+}
+
diff --git a/src/main/java/group/goforward/battlbuilder/model/ImageMeta.java b/src/main/java/group/goforward/battlbuilder/model/ImageMeta.java
new file mode 100644
index 0000000..881fa57
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/model/ImageMeta.java
@@ -0,0 +1,67 @@
+package group.goforward.battlbuilder.model;
+import jakarta.persistence.*;
+import java.time.Instant;
+
+@Entity
+@Table(name = "image_meta")
+public class ImageMeta {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 255)
+ private String filename;
+
+ @Column(nullable = false, length = 100)
+ private String contentType; // e.g., "image/jpeg", "image/png"
+
+ @Column(nullable = false)
+ private long size; // file size in bytes
+
+ @Column(nullable = false, length = 64)
+ private String sha256; // hex-encoded SHA-256 hash used for ETag
+
+ @Column(nullable = false)
+ private Long ownerId; // Who is allowed to access this image
+
+ @Column(nullable = false)
+ private Instant uploadedAt = Instant.now();
+
+ public ImageMeta() {}
+
+ public ImageMeta(String filename, String contentType, long size, String sha256, Long ownerId) {
+ this.filename = filename;
+ this.contentType = contentType;
+ this.size = size;
+ this.sha256 = sha256;
+ this.ownerId = ownerId;
+ this.uploadedAt = Instant.now();
+ }
+
+ // Getters and setters
+ public Long getId() { return id; }
+
+ public String getFilename() { return filename; }
+ public void setFilename(String filename) { this.filename = filename; }
+
+ public String getContentType() { return contentType; }
+ public void setContentType(String contentType) { this.contentType = contentType; }
+
+ public long getSize() { return size; }
+ public void setSize(long size) { this.size = size; }
+
+ public String getSha256() { return sha256; }
+ public void setSha256(String sha256) { this.sha256 = sha256; }
+
+ public Long getOwnerId() { return ownerId; }
+ public void setOwnerId(Long ownerId) { this.ownerId = ownerId; }
+
+ public Instant getUploadedAt() { return uploadedAt; }
+ public void setUploadedAt(Instant uploadedAt) { this.uploadedAt = uploadedAt; }
+
+ /** Returns a correct HTTP ETag string */
+ public String getEtag() {
+ return "\"" + sha256 + "\"";
+ }
+}
diff --git a/src/main/java/group/goforward/battlbuilder/repos/ImageBlobRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ImageBlobRepository.java
new file mode 100644
index 0000000..8867a79
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/repos/ImageBlobRepository.java
@@ -0,0 +1,7 @@
+package group.goforward.battlbuilder.repos;
+
+import group.goforward.battlbuilder.model.ImageBlob;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ImageBlobRepository extends JpaRepository {
+}
diff --git a/src/main/java/group/goforward/battlbuilder/repos/ImageMetaRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ImageMetaRepository.java
new file mode 100644
index 0000000..0d400f8
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/repos/ImageMetaRepository.java
@@ -0,0 +1,8 @@
+package group.goforward.battlbuilder.repos;
+
+import group.goforward.battlbuilder.model.ImageMeta;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ImageMetaRepository extends JpaRepository {
+}
+
diff --git a/src/main/java/group/goforward/battlbuilder/security/UserPrincipal.java b/src/main/java/group/goforward/battlbuilder/security/UserPrincipal.java
new file mode 100644
index 0000000..bd1664f
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/security/UserPrincipal.java
@@ -0,0 +1,23 @@
+package group.goforward.battlbuilder.security;
+
+
+public class UserPrincipal {
+ private final Long id;
+ private final boolean admin;
+
+ public UserPrincipal(Long id, boolean admin) {
+ this.id = id;
+ this.admin = admin;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public boolean isAdmin() {
+ return admin;
+ }
+
+
+}
+
diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/ImageService.java b/src/main/java/group/goforward/battlbuilder/services/impl/ImageService.java
new file mode 100644
index 0000000..3f81a16
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/services/impl/ImageService.java
@@ -0,0 +1,124 @@
+package group.goforward.battlbuilder.services.impl;
+
+import group.goforward.battlbuilder.model.ImageBlob;
+import group.goforward.battlbuilder.model.ImageMeta;
+import group.goforward.battlbuilder.repos.ImageBlobRepository;
+import group.goforward.battlbuilder.repos.ImageMetaRepository;
+
+import group.goforward.battlbuilder.security.UserPrincipal;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.HexFormat;
+
+@Service
+public class ImageService {
+
+ private final ImageMetaRepository metaRepository;
+ private final ImageBlobRepository blobRepository;
+
+ public ImageService(ImageMetaRepository metaRepository,
+ ImageBlobRepository blobRepository) {
+ this.metaRepository = metaRepository;
+ this.blobRepository = blobRepository;
+ }
+
+ /**
+ * Load metadata for an image and ensure the current user is allowed to see it.
+ *
+ * @throws jakarta.persistence.EntityNotFoundException if not found
+ * @throws org.springframework.security.access.AccessDeniedException if not authorized
+ */
+ @Transactional(readOnly = true)
+ public ImageMeta requireAuthorizedMeta(long id, UserPrincipal user) {
+ ImageMeta meta = metaRepository.findById(id)
+ .orElseThrow(() -> new jakarta.persistence.EntityNotFoundException("Image not found: " + id));
+
+ if (!isAuthorized(meta, user)) {
+ throw new org.springframework.security.access.AccessDeniedException("Not allowed to access this image");
+ }
+
+ return meta;
+ }
+
+ private boolean isAuthorized(ImageMeta meta, UserPrincipal user) {
+ if (user == null) return false;
+ if (user.isAdmin()) return true;
+ return meta.getOwnerId() != null && meta.getOwnerId().equals(user.getId());
+ }
+
+ /**
+ * Open an InputStream for the image data.
+ * (Currently loads the blob into memory; acceptable for moderate sizes.)
+ */
+ @Transactional(readOnly = true)
+ public InputStream openImageStream(long id) {
+ ImageBlob blob = blobRepository.findById(id)
+ .orElseThrow(() -> new jakarta.persistence.EntityNotFoundException("Image data not found: " + id));
+
+ return new ByteArrayInputStream(blob.getData());
+ }
+
+ /**
+ * Store a new image for the given owner.
+ * Returns the persisted ImageMeta.
+ */
+ @Transactional
+ public ImageMeta storeImage(MultipartFile file, Long ownerId) {
+ try {
+ byte[] bytes = file.getBytes();
+ String sha256 = sha256Hex(bytes);
+
+ ImageMeta meta = new ImageMeta(
+ file.getOriginalFilename(),
+ file.getContentType() != null ? file.getContentType() : "application/octet-stream",
+ bytes.length,
+ sha256,
+ ownerId
+ );
+ meta.setUploadedAt(Instant.now());
+
+ // Save meta first so it gets an ID
+ meta = metaRepository.save(meta);
+
+ // Save blob using same ID
+ ImageBlob blob = new ImageBlob(meta.getId(), bytes);
+ blobRepository.save(blob);
+
+ return meta;
+
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to store image", e);
+ }
+ }
+
+ /**
+ * Delete image (meta + blob) – useful for cleanup.
+ */
+ @Transactional
+ public void deleteImage(long id, UserPrincipal user) {
+ ImageMeta meta = requireAuthorizedMeta(id, user);
+ blobRepository.deleteById(meta.getId());
+ metaRepository.deleteById(meta.getId());
+ }
+
+ // --- Helpers ---
+
+ private String sha256Hex(byte[] data) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(data);
+ return HexFormat.of().formatHex(hash);
+ } catch (Exception e) {
+ throw new RuntimeException("Could not compute SHA-256", e);
+ }
+ }
+}
+
diff --git a/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java
index e5221bd..cc0477a 100644
--- a/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java
+++ b/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java
@@ -5,11 +5,13 @@ import group.goforward.battlbuilder.repos.EmailRequestRepository;
import group.goforward.battlbuilder.services.utils.EmailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
-import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import jakarta.mail.internet.MimeMessage;
+import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
@Service
@@ -32,23 +34,31 @@ public class EmailServiceImpl implements EmailService {
emailRequest.setSubject(subject);
emailRequest.setBody(body);
emailRequest.setStatus("PENDING");
-
+
emailRequest = emailRequestRepository.save(emailRequest);
try {
- // Send email
- SimpleMailMessage message = new SimpleMailMessage();
- message.setFrom(fromEmail);
- message.setTo(recipient);
- message.setSubject(subject);
- message.setText(body);
-
+ // Send email as HTML
+ MimeMessage message = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(
+ message,
+ false,
+ StandardCharsets.UTF_8.name()
+ );
+
+ helper.setFrom(fromEmail);
+ helper.setTo(recipient);
+ helper.setSubject(subject);
+
+ // IMPORTANT: second argument 'true' means "this is HTML"
+ helper.setText(body, true);
+
mailSender.send(message);
// Update status
emailRequest.setStatus("SENT");
emailRequest.setSentAt(LocalDateTime.now());
-
+
} catch (Exception e) {
// Update status with error
emailRequest.setStatus("FAILED");
@@ -60,6 +70,6 @@ public class EmailServiceImpl implements EmailService {
@Override
public void deleteById(Integer id) {
- deleteById(id);
+ emailRequestRepository.deleteById(Long.valueOf(id));
}
-}
+}
\ No newline at end of file