mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
Images and Image Meta data, with Controllers and repos
This commit is contained in:
56
sql/ImageMetaManagement.sql
Normal file
56
sql/ImageMetaManagement.sql
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user