Images and Image Meta data, with Controllers and repos

This commit is contained in:
2025-12-15 22:03:59 -05:00
parent 1382f8c906
commit 1cfcf68f36
12 changed files with 461 additions and 12 deletions

View File

@@ -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);

View File

@@ -0,0 +1,12 @@
/**
* Catalog package for the BattlBuilder application.
* <p>
* 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;

View File

@@ -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<StreamingResponseBody> 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);
}
}

View File

@@ -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<ApiResponse<EmailRequest> | { error: string } | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
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 (
<form onSubmit={onSubmit}>
<input name="recipient" placeholder="recipient" defaultValue="user@example.com" />
<input name="subject" placeholder="subject" defaultValue="Test subject" />
<textarea name="body" placeholder="body" defaultValue="Test body" />
<button type="submit">Send</button>
<pre style={{ marginTop: 12 }}>
{result ? JSON.stringify(result, null, 2) : "No response yet."}
</pre>
</form>
);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 + "\"";
}
}

View File

@@ -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<ImageBlob, Long> {
}

View File

@@ -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<ImageMeta, Long> {
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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));
}
}
}