Archived
Admin for the menu and enquiries, plus gallery fixes
Admin - /api/admin: products CRUD, the enquiry inbox, and presigned photo upload straight to the bucket so images never pass through the app. Gated by platform.security.authenticated-paths = /api/admin/**, so any signed-in Authentik user is staff — the alternative is a role model a two-person bakery would never maintain. - /api/me is deliberately PUBLIC. The SPA asks on every page load, and requiring a login would bounce every anonymous visitor to Authentik just to read the menu. - /admin screens: product list with edit and remove, an editor with drag-free photo reordering and upload, and an enquiry inbox that flags anything the relay refused. Gallery - swipe on touch devices, which the react-awesome-slider it replaced had and this did not, plus arrow keys and position dots — with swipe there is otherwise nothing to say a card holds more than one photo. Vertical drags are ignored so page scrolling still works. - @BatchSize on the photo collection: the products page loaded the whole catalogue and Hibernate issued a query per product for its images, forty-odd round trips for a page that needs two. Three things the tests caught, none of which are obvious: - Adding the storage starter broke every existing test. It activates on a default endpoint, so an S3 client is built even in tests and dies on blank keys. - MockMvc's webAppContextSetup leaves the security filter chain OUT, so the first version of the security test passed 200s and proved the opposite of what it claimed. It needs .apply(springSecurity()). - Turning on the security starter turns on CSRF — for the PUBLIC contact form too, which then 403s. The SPA now reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN, and there is a test asserting the form is rejected without it. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.itsthevine.web.domain.ContactEnquiry;
|
||||
import com.itsthevine.web.domain.ContactEnquiryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
import net.thebennett.platform.storage.StorageService;
|
||||
|
||||
/**
|
||||
* Everything behind the login: the catalogue, and the enquiries people have sent.
|
||||
*
|
||||
* <p>The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
|
||||
* any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
|
||||
* the alternative is a role model nobody would maintain.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final ContactEnquiryRepository enquiries;
|
||||
private final StorageService storage;
|
||||
private final String bucket;
|
||||
private final String publicBaseUrl;
|
||||
|
||||
public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
|
||||
StorageService storage,
|
||||
@Value("${vine.storage.bucket:itsthevine}") String bucket,
|
||||
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
|
||||
this.products = products;
|
||||
this.enquiries = enquiries;
|
||||
this.storage = storage;
|
||||
this.bucket = bucket;
|
||||
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
// ---- products ----
|
||||
|
||||
/** @param imageKeys bucket keys, in display order; the first is the one the card shows */
|
||||
public record ProductForm(String name, String category, Integer position, List<String> imageKeys) {}
|
||||
|
||||
public record AdminProduct(Long id, String name, String category, int position,
|
||||
List<String> imageKeys, List<String> imageUrls) {}
|
||||
|
||||
@GetMapping("/products")
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminProduct> list() {
|
||||
return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/products")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Transactional
|
||||
public AdminProduct create(@RequestBody ProductForm form) {
|
||||
validate(form);
|
||||
// Default to the end of the list so a new product does not silently displace an existing one.
|
||||
int position = form.position() != null ? form.position() : nextPosition();
|
||||
return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
|
||||
position, cleanKeys(form.imageKeys()))));
|
||||
}
|
||||
|
||||
@PutMapping("/products/{id}")
|
||||
@Transactional
|
||||
public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
|
||||
validate(form);
|
||||
Product p = products.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
|
||||
p.update(form.name().trim(), form.category().trim(),
|
||||
form.position() != null ? form.position() : p.getPosition(),
|
||||
cleanKeys(form.imageKeys()));
|
||||
return toAdmin(p);
|
||||
}
|
||||
|
||||
@DeleteMapping("/products/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@Transactional
|
||||
public void delete(@PathVariable Long id) {
|
||||
if (!products.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
|
||||
}
|
||||
// The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
|
||||
// the images survive it.
|
||||
products.deleteById(id);
|
||||
}
|
||||
|
||||
// ---- enquiries ----
|
||||
|
||||
/** @param delivered false means the relay refused it and nobody was notified */
|
||||
public record AdminEnquiry(Long id, String name, String email, String message,
|
||||
boolean delivered, Instant receivedAt) {}
|
||||
|
||||
@GetMapping("/enquiries")
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminEnquiry> enquiries() {
|
||||
return enquiries.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
|
||||
e.isDelivered(), e.getCreatedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ---- photo upload ----
|
||||
|
||||
/**
|
||||
* @param key what to store on the product
|
||||
* @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
|
||||
* never passes through this app
|
||||
* @param publicUrl where it will be readable from afterwards
|
||||
*/
|
||||
public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
|
||||
|
||||
@PostMapping("/images/presign-upload")
|
||||
public UploadTarget presignUpload(@RequestParam String filename,
|
||||
@RequestParam(defaultValue = "application/octet-stream") String contentType) {
|
||||
// A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
|
||||
// overwrite each other, and the bucket is public so keys should not be guessable.
|
||||
String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
|
||||
String key = "images/products/" + UUID.randomUUID() + "-" + safe;
|
||||
return new UploadTarget(key.substring("images/".length()),
|
||||
storage.presignPut(bucket, key, contentType).toString(),
|
||||
publicBaseUrl + "/" + key);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void validate(ProductForm form) {
|
||||
if (form.name() == null || form.name().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
|
||||
}
|
||||
if (form.category() == null || form.category().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
|
||||
}
|
||||
if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> cleanKeys(List<String> keys) {
|
||||
return keys == null ? List.of()
|
||||
: keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
|
||||
}
|
||||
|
||||
private int nextPosition() {
|
||||
return products.findAllByOrderByPositionAsc().stream()
|
||||
.mapToInt(Product::getPosition).max().orElse(0) + 1;
|
||||
}
|
||||
|
||||
private AdminProduct toAdmin(Product p) {
|
||||
return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
|
||||
p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
|
||||
}
|
||||
|
||||
private String publicUrl(String key) {
|
||||
return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Who, if anyone, is signed in.
|
||||
*
|
||||
* <p>Deliberately PUBLIC: the SPA asks on every page load, and if this required a login the site would
|
||||
* bounce anonymous visitors — every one of them — to Authentik just to render the front page.
|
||||
*/
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
/** @param admin true for any signed-in user; there is one level of access here */
|
||||
public record Me(boolean authenticated, boolean admin, String name) {}
|
||||
|
||||
@GetMapping("/api/me")
|
||||
public Me me() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
boolean signedIn = auth != null && auth.isAuthenticated()
|
||||
&& !"anonymousUser".equals(auth.getPrincipal());
|
||||
if (!signedIn) {
|
||||
return new Me(false, false, null);
|
||||
}
|
||||
String name = auth.getName();
|
||||
if (auth.getPrincipal() instanceof OidcUser user) {
|
||||
name = user.getPreferredUsername() != null ? user.getPreferredUsername() : user.getSubject();
|
||||
}
|
||||
return new Me(true, true, name);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.itsthevine.web.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ContactEnquiryRepository extends JpaRepository<ContactEnquiry, Long> {
|
||||
|
||||
/** Newest first — the admin screen reads like an inbox. */
|
||||
List<ContactEnquiry> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OrderColumn;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import org.hibernate.annotations.BatchSize;
|
||||
|
||||
import net.thebennett.platform.data.BaseEntity;
|
||||
|
||||
/** Something the bakery makes, with the photos that show it off. */
|
||||
@@ -32,17 +34,40 @@ public class Product extends BaseEntity {
|
||||
/**
|
||||
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
|
||||
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
|
||||
*
|
||||
* <p>{@code @BatchSize} because the products page loads the whole catalogue at once: without it
|
||||
* Hibernate issues a separate query per product for its photos — forty-odd round trips for a page
|
||||
* that needs two.
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
|
||||
@OrderColumn(name = "position")
|
||||
@Column(name = "image_key", nullable = false, length = 300)
|
||||
@BatchSize(size = 64)
|
||||
private List<String> imageKeys = new ArrayList<>();
|
||||
|
||||
protected Product() {
|
||||
// for JPA
|
||||
}
|
||||
|
||||
public Product(String name, String category, int position, List<String> imageKeys) {
|
||||
this.name = name;
|
||||
this.category = category;
|
||||
this.position = position;
|
||||
this.imageKeys = new ArrayList<>(imageKeys);
|
||||
}
|
||||
|
||||
/** Replaces every editable field — the admin form always submits the whole product. */
|
||||
public void update(String name, String category, int position, List<String> imageKeys) {
|
||||
this.name = name;
|
||||
this.category = category;
|
||||
this.position = position;
|
||||
// Mutate in place rather than reassigning: Hibernate tracks THIS list instance, and handing it
|
||||
// a different one makes it delete and re-insert every row.
|
||||
this.imageKeys.clear();
|
||||
this.imageKeys.addAll(imageKeys);
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public String getCategory() { return category; }
|
||||
public int getPosition() { return position; }
|
||||
|
||||
@@ -36,11 +36,27 @@ platform:
|
||||
data:
|
||||
auditing:
|
||||
enabled: true
|
||||
security:
|
||||
# Public site: only the admin API needs a login. An allowlist of public paths would mean
|
||||
# enumerating every static directory, and anything missed 401s — which is exactly how the
|
||||
# confessions site broke its own cover images. mode=OIDC comes from the deploy env so tests
|
||||
# stay on NONE.
|
||||
authenticated-paths:
|
||||
- /api/admin/**
|
||||
storage:
|
||||
endpoint: ${S3_ENDPOINT:https://s3.thebennett.net}
|
||||
access-key: ${S3_ACCESS_KEY:}
|
||||
secret-key: ${S3_SECRET_KEY:}
|
||||
path-style-access: true
|
||||
contact:
|
||||
to: ${CONTACT_TO:}
|
||||
from: ${CONTACT_FROM:}
|
||||
hub-url: ${CONTACT_HUB_URL:}
|
||||
|
||||
vine:
|
||||
storage:
|
||||
bucket: ${VINE_BUCKET:itsthevine}
|
||||
|
||||
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
||||
site:
|
||||
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
||||
|
||||
Reference in New Issue
Block a user