diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c6a5a6..f5502f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,9 @@ import ProductsPage from '@/pages/Products'; import HistoryPage from '@/pages/History'; import ContactPage from '@/pages/Contact'; import NotFoundPage from '@/pages/NotFound'; +import AdminPage from '@/pages/admin/AdminPage'; +import ProductEditorPage from '@/pages/admin/ProductEditorPage'; +import { AuthProvider } from '@/lib/auth'; /** * Client-side navigation keeps the previous scroll position, which lands you halfway down a page you @@ -23,6 +26,7 @@ const ScrollToTop = () => { }; const App = () => ( +
@@ -32,11 +36,15 @@ const App = () => ( } /> } /> } /> + } /> + } /> + } /> } />
+
); export default App; diff --git a/frontend/src/components/ProductGallery.tsx b/frontend/src/components/ProductGallery.tsx index f89ef9c..30d929b 100644 --- a/frontend/src/components/ProductGallery.tsx +++ b/frontend/src/components/ProductGallery.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; interface ProductGalleryProps { images: string[]; @@ -11,16 +11,51 @@ interface ProductGalleryProps { * Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to * React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a * build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop, - * arrows only when they'd do something. + * arrows only when they'd do something — plus swipe and keyboard, which the old slider had on touch + * devices and the first version of this did not. */ + +/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */ +const SWIPE_THRESHOLD = 40; + const ProductGallery: React.FC = ({ images, alt }) => { const [index, setIndex] = useState(0); const many = images.length > 1; + const touchStart = useRef<{ x: number; y: number } | null>(null); const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length); + const onTouchStart = (e: React.TouchEvent) => { + const t = e.touches[0]; + touchStart.current = { x: t.clientX, y: t.clientY }; + }; + + const onTouchEnd = (e: React.TouchEvent) => { + const start = touchStart.current; + touchStart.current = null; + if (!start || !many) return; + const t = e.changedTouches[0]; + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + // Ignore anything more vertical than horizontal — that is the page being scrolled, not a swipe. + if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return; + step(dx < 0 ? 1 : -1); + }; + return ( -
+
{ + if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); step(1); } + } : undefined} + tabIndex={many ? 0 : undefined} + role={many ? 'group' : undefined} + aria-roledescription={many ? 'carousel' : undefined} + aria-label={many ? `${alt} — ${images.length} photos` : undefined} + > {images.map((src, i) => ( = ({ images, alt }) => { > + {/* Which of how many. The old slider ran with bullets off, but once a card can be swiped + there is otherwise nothing to say it holds more than one photo. */} + )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d8d518c..595eeb0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,16 @@ export interface Product { images: string[]; } +/** + * Spring Security protects every mutating request with a CSRF token, and the platform's security + * starter writes it to a readable XSRF-TOKEN cookie. Without this header a POST is rejected 403 — + * including the public contact form, which is not obvious until the form stops working. + */ +function csrfHeaders(): Record { + const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1]; + return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {}; +} + async function get(path: string): Promise { const res = await fetch(path, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(`${path} responded ${res.status}`); @@ -21,3 +31,83 @@ export const fetchProducts = (category?: string) => get(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products'); export const fetchCategories = () => get('/api/categories'); + +// ---- who is signed in (public: the SPA asks on every page load) ---- +export interface Me { authenticated: boolean; admin: boolean; name: string | null } +export const fetchMe = () => get('/api/me'); + +// ---- admin (everything below needs an Authentik login) ---- +export interface AdminProduct { + id: number; + name: string; + category: string; + position: number; + imageKeys: string[]; + imageUrls: string[]; +} +export interface AdminEnquiry { + id: number; + name: string; + email: string; + message: string; + delivered: boolean; + receivedAt: string; +} +export interface ProductForm { + name: string; + category: string; + position: number | null; + imageKeys: string[]; +} + +async function send(path: string, method: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + // The backend puts a human-readable reason in `detail`; show that rather than a status code. + let detail = `${method} ${path} responded ${res.status}`; + try { detail = (await res.json()).detail ?? detail; } catch { /* not JSON */ } + throw new Error(detail); + } + return res.status === 204 ? (undefined as T) : (res.json() as Promise); +} + +export const fetchAdminProducts = () => get('/api/admin/products'); +export const createProduct = (f: ProductForm) => send('/api/admin/products', 'POST', f); +export const updateProduct = (id: number, f: ProductForm) => + send(`/api/admin/products/${id}`, 'PUT', f); +export const deleteProduct = (id: number) => send(`/api/admin/products/${id}`, 'DELETE'); +export const fetchEnquiries = () => get('/api/admin/enquiries'); + +export interface UploadTarget { key: string; uploadUrl: string; publicUrl: string } + +/** Presign, then PUT the file straight to the bucket — the photo never passes through the app. */ +export async function uploadPhoto(file: File): Promise { + const target = await send( + `/api/admin/images/presign-upload?filename=${encodeURIComponent(file.name)}` + + `&contentType=${encodeURIComponent(file.type || 'application/octet-stream')}`, + 'POST'); + // Straight to the bucket, so no CSRF header here — it is a different origin and a presigned URL. + const put = await fetch(target.uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + body: file, + }); + if (!put.ok) throw new Error(`the bucket rejected the upload (${put.status})`); + return target; +} + +/** The public contact form. Mutating, so it needs the CSRF token too. */ +export async function submitContact(input: { name: string; email: string; message: string }) { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, + body: JSON.stringify(input), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Could not send the message.'); + return data; +} diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..0bfd4c1 --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { fetchMe, type Me } from './api'; + +const ANON: Me = { authenticated: false, admin: false, name: null }; + +const AuthContext = createContext<{ me: Me; loading: boolean }>({ me: ANON, loading: true }); + +/** + * Resolves the signed-in user from the PUBLIC /api/me. + * + * It has to be public: this runs on every page load, and if it required a login every anonymous + * visitor would be bounced to Authentik just to read the menu. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState<{ me: Me; loading: boolean }>({ me: ANON, loading: true }); + + useEffect(() => { + fetchMe() + .then((me) => setState({ me, loading: false })) + .catch(() => setState({ me: ANON, loading: false })); + }, []); + + return {children}; +} + +export const useAuth = () => useContext(AuthContext); + +/** Full-page navigation, not fetch: the OIDC handshake is a redirect chain the browser must follow. */ +export const signIn = () => { window.location.href = '/oauth2/authorization/authentik'; }; diff --git a/frontend/src/pages/Contact.tsx b/frontend/src/pages/Contact.tsx index a6089e9..203a7a4 100644 --- a/frontend/src/pages/Contact.tsx +++ b/frontend/src/pages/Contact.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { submitContact } from '@/lib/api'; type Status = 'idle' | 'sending' | 'sent' | 'error'; @@ -23,13 +24,7 @@ const ContactPage = () => { setStatus('sending'); setError(''); try { - const res = await fetch('/api/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Could not send the message.'); + await submitContact(formData); setStatus('sent'); setFormData({ name: '', email: '', message: '' }); } catch (err) { diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx new file mode 100644 index 0000000..63f2615 --- /dev/null +++ b/frontend/src/pages/admin/AdminPage.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + deleteProduct, fetchAdminProducts, fetchEnquiries, + type AdminEnquiry, type AdminProduct, +} from '@/lib/api'; +import { useAuth, signIn } from '@/lib/auth'; + +type Tab = 'products' | 'enquiries'; + +export default function AdminPage() { + const { me, loading } = useAuth(); + const [tab, setTab] = useState('products'); + const [products, setProducts] = useState([]); + const [enquiries, setEnquiries] = useState([]); + const [error, setError] = useState(''); + + const load = () => { + // Only ask for admin data once we know there is a session; otherwise every anonymous visitor + // who guesses this URL gets bounced to the identity provider. + if (!me.admin) return; + fetchAdminProducts().then(setProducts).catch((e) => setError(String(e.message ?? e))); + fetchEnquiries().then(setEnquiries).catch((e) => setError(String(e.message ?? e))); + }; + + useEffect(load, [me.admin]); + + if (loading) { + return

Loading…

; + } + + if (!me.admin) { + return ( +
+

Staff only

+ +
+ ); + } + + async function remove(p: AdminProduct) { + if (!window.confirm(`Remove “${p.name}” from the menu? The photos stay in storage.`)) return; + try { + await deleteProduct(p.id); + setProducts((list) => list.filter((x) => x.id !== p.id)); + } catch (e) { + setError(String((e as Error).message)); + } + } + + return ( +
+
+

Manage

+ signed in as {me.name} +
+ + {error && ( +

{error}

+ )} + +
+ {(['products', 'enquiries'] as Tab[]).map((t) => ( + + ))} +
+ + {tab === 'products' ? ( + <> + + Add a product + +
+ {products.map((p) => ( +
+ {p.imageUrls[0] && ( + + )} +
+
{p.name}
+
+ {p.category} · {p.imageKeys.length} photo{p.imageKeys.length === 1 ? '' : 's'} +
+
+ + Edit + + +
+
+
+ ))} + {products.length === 0 &&

Nothing on the menu yet.

} +
+ + ) : ( +
+ {enquiries.map((e) => ( +
+
+
+ {e.name} {e.email} +
+
+ {new Date(e.receivedAt).toLocaleString()} + {!e.delivered && ( + // The enquiry was saved but the relay refused it — nobody got an email. + + not emailed + + )} +
+
+

{e.message}

+
+ ))} + {enquiries.length === 0 &&

No enquiries yet.

} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/admin/ProductEditorPage.tsx b/frontend/src/pages/admin/ProductEditorPage.tsx new file mode 100644 index 0000000..1ffed52 --- /dev/null +++ b/frontend/src/pages/admin/ProductEditorPage.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + createProduct, fetchAdminProducts, updateProduct, uploadPhoto, + type AdminProduct, +} from '@/lib/api'; +import { useAuth, signIn } from '@/lib/auth'; + +export default function ProductEditorPage() { + const { id } = useParams(); + const editing = id !== undefined; + const navigate = useNavigate(); + const { me, loading } = useAuth(); + + const [name, setName] = useState(''); + const [category, setCategory] = useState(''); + const [position, setPosition] = useState(null); + const [images, setImages] = useState<{ key: string; url: string }[]>([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (!editing || !me.admin) return; + fetchAdminProducts() + .then((all) => { + const p = all.find((x: AdminProduct) => String(x.id) === id); + if (!p) { setError('That product no longer exists.'); return; } + setName(p.name); + setCategory(p.category); + setPosition(p.position); + setImages(p.imageKeys.map((k, i) => ({ key: k, url: p.imageUrls[i] }))); + }) + .catch((e) => setError(String(e.message ?? e))); + }, [editing, id, me.admin]); + + if (loading) return

Loading…

; + if (!me.admin) { + return ( +
+ +
+ ); + } + + async function onFiles(files: FileList | null) { + if (!files?.length) return; + setBusy(true); setError(''); + try { + // Sequentially, so the order the photos are chosen is the order they appear on the card. + for (const file of Array.from(files)) { + const t = await uploadPhoto(file); + setImages((list) => [...list, { key: t.key, url: t.publicUrl }]); + } + } catch (e) { + setError(String((e as Error).message)); + } finally { + setBusy(false); + } + } + + async function save(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); setError(''); + try { + const form = { name, category, position, imageKeys: images.map((i) => i.key) }; + if (editing) await updateProduct(Number(id), form); + else await createProduct(form); + navigate('/admin'); + } catch (err) { + setError(String((err as Error).message)); + setBusy(false); + } + } + + const move = (from: number, to: number) => { + if (to < 0 || to >= images.length) return; + setImages((list) => { + const next = [...list]; + const [it] = next.splice(from, 1); + next.splice(to, 0, it); + return next; + }); + }; + + return ( +
+

+ {editing ? 'Edit product' : 'Add a product'} +

+ + {error &&

{error}

} + +
+ + setName(e.target.value)} required + className="mb-5 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500" + /> + + + setCategory(e.target.value)} required + placeholder="Cakes, Cookies, Rolls, Pie, Brownies, Pastries" + className="mb-1 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500" + /> +

+ A new category appears as its own filter button, after the ones the site already knows. +

+ + + onFiles(e.target.files)} + className="mb-4 block w-full text-sm text-bakery-700 file:mr-4 file:rounded-full file:border-0 file:bg-bakery-600 file:px-5 file:py-2 file:text-white" + /> + + {images.length > 0 && ( +
+ {images.map((img, i) => ( +
+ + {i === 0 && ( + + card + + )} +
+ + + +
+
+ ))} +
+ )} + +
+ + + {images.length === 0 && Add at least one photo.} +
+
+
+ ); +} diff --git a/pom.xml b/pom.xml index 8d928d0..f558983 100644 --- a/pom.xml +++ b/pom.xml @@ -62,8 +62,16 @@ net.thebennett.platform platform-starter-contact - + + + net.thebennett.platform + platform-starter-security + + + net.thebennett.platform + platform-starter-storage + @@ -77,6 +85,13 @@ spring-boot-starter-test test + + + org.springframework.security + spring-security-test + test + org.springframework.boot spring-boot-testcontainers diff --git a/src/main/java/com/itsthevine/web/AdminController.java b/src/main/java/com/itsthevine/web/AdminController.java new file mode 100644 index 0000000..66a0535 --- /dev/null +++ b/src/main/java/com/itsthevine/web/AdminController.java @@ -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. + * + *

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 imageKeys) {} + + public record AdminProduct(Long id, String name, String category, int position, + List imageKeys, List imageUrls) {} + + @GetMapping("/products") + @Transactional(readOnly = true) + public List 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 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 cleanKeys(List 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("^/+", ""); + } +} diff --git a/src/main/java/com/itsthevine/web/MeController.java b/src/main/java/com/itsthevine/web/MeController.java new file mode 100644 index 0000000..e77cea0 --- /dev/null +++ b/src/main/java/com/itsthevine/web/MeController.java @@ -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. + * + *

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); + } +} diff --git a/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java b/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java index d8ce5c2..7306721 100644 --- a/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java +++ b/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java @@ -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 { + + /** Newest first — the admin screen reads like an inbox. */ + List findAllByOrderByCreatedAtDesc(); } diff --git a/src/main/java/com/itsthevine/web/domain/Product.java b/src/main/java/com/itsthevine/web/domain/Product.java index edfa30c..e3114f0 100644 --- a/src/main/java/com/itsthevine/web/domain/Product.java +++ b/src/main/java/com/itsthevine/web/domain/Product.java @@ -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. + * + *

{@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 imageKeys = new ArrayList<>(); protected Product() { // for JPA } + public Product(String name, String category, int position, List 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 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; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 30afc48..ea7a05b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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} diff --git a/src/test/java/com/itsthevine/web/AdminControllerTest.java b/src/test/java/com/itsthevine/web/AdminControllerTest.java new file mode 100644 index 0000000..b92302c --- /dev/null +++ b/src/test/java/com/itsthevine/web/AdminControllerTest.java @@ -0,0 +1,146 @@ +package com.itsthevine.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.web.server.ResponseStatusException; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import com.itsthevine.web.domain.ProductRepository; + +/** + * The admin catalogue operations. Whether they're reachable without a login is covered separately by + * {@link AdminSecurityTest} — this is about what they do once you're in. + */ +@SpringBootTest(properties = { + "platform.contact.to=test@example.com", + "platform.contact.from=noreply@example.com", + "platform.storage.access-key=test", + "platform.storage.secret-key=test", + "site.assets.base-url=https://s3.example.test/itsthevine" +}) +@Testcontainers +class AdminControllerTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + AdminController admin; + + @Autowired + ProductCatalog catalog; + + @Autowired + ProductRepository products; + + private static AdminController.ProductForm form(String name, String category, List keys) { + return new AdminController.ProductForm(name, category, null, keys); + } + + @Test + void createsAProductAndItAppearsOnThePublicSite() { + int before = catalog.list(null).size(); + + var created = admin.create(form("Test Loaf", "Rolls", List.of("products/test-loaf.webp"))); + + assertThat(created.id()).isNotNull(); + assertThat(catalog.list(null)).hasSize(before + 1); + assertThat(catalog.list("Rolls")) + .extracting(ProductCatalog.ProductView::name) + .contains("Test Loaf"); + + admin.delete(created.id()); + } + + @Test + void aNewProductGoesToTheEndRatherThanDisplacingOne() { + // Position defaults matter: reusing an existing one would reorder the curated catalogue. + int maxBefore = admin.list().stream().mapToInt(AdminController.AdminProduct::position).max().orElse(0); + + var created = admin.create(form("末 Loaf", "Rolls", List.of("products/x.webp"))); + + assertThat(created.position()).isGreaterThan(maxBefore); + admin.delete(created.id()); + } + + @Test + void editingReplacesTheFieldsAndKeepsTheOrderOfPhotos() { + var created = admin.create(form("Before", "Cakes", List.of("products/a.webp", "products/b.webp"))); + + var updated = admin.update(created.id(), + new AdminController.ProductForm("After", "Pie", 3, + List.of("products/b.webp", "products/a.webp", "products/c.webp"))); + + assertThat(updated.name()).isEqualTo("After"); + assertThat(updated.category()).isEqualTo("Pie"); + assertThat(updated.position()).isEqualTo(3); + assertThat(updated.imageKeys()) + .containsExactly("products/b.webp", "products/a.webp", "products/c.webp"); + + admin.delete(created.id()); + } + + @Test + void aProductWithoutAPhotoIsRejected() { + // The card is a photo with a caption; without one it renders as an empty square. + assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of()))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("at least one photo"); + assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of(" ")))) + .isInstanceOf(ResponseStatusException.class); + } + + @Test + void aProductWithoutANameOrCategoryIsRejected() { + assertThatThrownBy(() -> admin.create(form(" ", "Cakes", List.of("products/a.webp")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("name"); + assertThatThrownBy(() -> admin.create(form("Thing", " ", List.of("products/a.webp")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("category"); + } + + @Test + void editingSomethingThatIsGoneIs404NotACrash() { + assertThatThrownBy(() -> admin.update(9_999_999L, form("x", "Cakes", List.of("products/a.webp")))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("404"); + assertThatThrownBy(() -> admin.delete(9_999_999L)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("404"); + } + + @Test + void deletingRemovesItFromThePublicCatalogue() { + var created = admin.create(form("Temporary", "Brownies", List.of("products/t.webp"))); + assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name).contains("Temporary"); + + admin.delete(created.id()); + + assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name) + .doesNotContain("Temporary"); + assertThat(products.findById(created.id())).isEmpty(); + } + + @Test + void adminListsCarryBothKeysAndUrlsSoTheEditorCanShowThumbnails() { + var created = admin.create(form("Thumb", "Cookies", List.of("products/thumb.webp"))); + + var found = admin.list().stream().filter(p -> p.id().equals(created.id())).findFirst().orElseThrow(); + assertThat(found.imageKeys()).containsExactly("products/thumb.webp"); + assertThat(found.imageUrls()) + .containsExactly("https://s3.example.test/itsthevine/images/products/thumb.webp"); + + admin.delete(created.id()); + } +} diff --git a/src/test/java/com/itsthevine/web/AdminSecurityTest.java b/src/test/java/com/itsthevine/web/AdminSecurityTest.java new file mode 100644 index 0000000..dd23000 --- /dev/null +++ b/src/test/java/com/itsthevine/web/AdminSecurityTest.java @@ -0,0 +1,113 @@ +package com.itsthevine.web; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * What an anonymous visitor can and cannot reach. + * + *

This is the test that matters most on this branch: the admin API can create, edit and delete the + * menu, and the whole site is otherwise public. Running with {@code platform.security.mode=OIDC}, as + * production does — the default of NONE would leave everything open and prove nothing. + */ +@SpringBootTest(properties = { + "platform.security.mode=OIDC", + // Endpoints stated outright rather than an issuer-uri: an issuer-uri makes Spring fetch the + // discovery document at startup, which needs the network and a real identity provider. + "spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize", + "spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token", + "spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks", + "spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo", + "spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username", + "spring.security.oauth2.client.registration.authentik.scope=openid,profile,email", + "spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", + "spring.security.oauth2.client.registration.authentik.client-id=test", + "spring.security.oauth2.client.registration.authentik.client-secret=test", + "platform.contact.to=test@example.com", + "platform.contact.from=noreply@example.com", + "platform.storage.access-key=test", + "platform.storage.secret-key=test" +}) +@Testcontainers +class AdminSecurityTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @BeforeEach + void setUp() { + // .apply(springSecurity()) is not optional here: webAppContextSetup alone leaves the security + // filter chain out, so every protected path returns 200 and the test proves nothing. + mvc = MockMvcBuilders.webAppContextSetup(context) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build(); + } + + @Test + void everyAdminEndpointIsClosedToAnonymousVisitors() throws Exception { + // 401 rather than a redirect: the platform's security starter answers /api/** with a status so + // the SPA can handle it, instead of bouncing an XHR to the identity provider. + mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized()); + mvc.perform(get("/api/admin/enquiries")).andExpect(status().isUnauthorized()); + mvc.perform(post("/api/admin/products").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"x\",\"category\":\"Cakes\",\"imageKeys\":[\"a\"]}")) + .andExpect(status().isUnauthorized()); + mvc.perform(post("/api/admin/images/presign-upload?filename=x.jpg").with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + void theShopStaysPublic() throws Exception { + // The whole point of authenticated-paths: locking the admin API must not lock the menu. + mvc.perform(get("/api/products")).andExpect(status().isOk()); + mvc.perform(get("/api/categories")).andExpect(status().isOk()); + mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}")) + .andExpect(status().isBadRequest()); // reached the controller, rejected on content + } + + @Test + void theContactFormNeedsItsCsrfToken() throws Exception { + // Turning on the security starter turns on CSRF, which applies to the PUBLIC contact form too. + // Without the token the form silently 403s — the SPA reads the XSRF-TOKEN cookie and sends + // X-XSRF-TOKEN for exactly this reason. + mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Ada\",\"email\":\"ada@example.com\",\"message\":\"hi\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void meIsPublicAndSaysNobodyIsSignedIn() throws Exception { + // If this required a login, every anonymous visitor would be bounced to Authentik on page load. + mvc.perform(get("/api/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andExpect(jsonPath("$.admin").value(false)); + } +} diff --git a/src/test/java/com/itsthevine/web/ContactControllerTest.java b/src/test/java/com/itsthevine/web/ContactControllerTest.java index aab6800..6cc41f7 100644 --- a/src/test/java/com/itsthevine/web/ContactControllerTest.java +++ b/src/test/java/com/itsthevine/web/ContactControllerTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -15,8 +16,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.mail.MailSendException; -import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import jakarta.mail.internet.MimeMessage; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -47,6 +50,8 @@ class ContactControllerTest { // Activates the contact starter without pointing it at anything real. registry.add("platform.contact.to", () -> "shop@example.com"); registry.add("platform.contact.from", () -> "noreply@example.com"); + registry.add("platform.storage.access-key", () -> "test"); + registry.add("platform.storage.secret-key", () -> "test"); } /** Nothing in a test run may reach a real relay. */ @@ -65,6 +70,9 @@ class ContactControllerTest { @BeforeEach void setUp() { + // The contact starter builds a MimeMessage through the sender (platform 0.1.6, so the display + // name is quoted properly). A bare mock returns null for that, so give it a real one. + when(mailSender.createMimeMessage()).thenAnswer(i -> new JavaMailSenderImpl().createMimeMessage()); mvc = MockMvcBuilders.webAppContextSetup(context).build(); enquiries.deleteAll(); } @@ -82,7 +90,7 @@ class ContactControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.ok").value(true)); - verify(mailSender).send(any(SimpleMailMessage.class)); + verify(mailSender).send(any(MimeMessage.class)); assertThat(enquiries.findAll()).singleElement().satisfies(e -> { assertThat(e.getName()).isEqualTo("Ada"); @@ -107,7 +115,7 @@ class ContactControllerTest { @Test void keepsTheEnquiryWhenTheRelayIsDown() throws Exception { // The whole reason the row is written before delivery: a broken relay must not lose business. - doThrow(new MailSendException("relay down")).when(mailSender).send(any(SimpleMailMessage.class)); + doThrow(new MailSendException("relay down")).when(mailSender).send(any(MimeMessage.class)); mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON) .content(body("Ada", "ada@example.com", "Cinnamon rolls for 30?"))) diff --git a/src/test/java/com/itsthevine/web/PlatformContractTest.java b/src/test/java/com/itsthevine/web/PlatformContractTest.java index 16bedb5..e3e1f6b 100644 --- a/src/test/java/com/itsthevine/web/PlatformContractTest.java +++ b/src/test/java/com/itsthevine/web/PlatformContractTest.java @@ -11,6 +11,10 @@ import net.thebennett.platform.test.PlatformWebContract; /** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */ @SpringBootTest(properties = { + // The storage starter activates on its default endpoint, so an S3 client is built even in + // tests and fails on blank keys. + "platform.storage.access-key=test", + "platform.storage.secret-key=test", "platform.contact.to=test@example.com", "platform.contact.from=noreply@example.com" }) diff --git a/src/test/java/com/itsthevine/web/ProductCatalogTest.java b/src/test/java/com/itsthevine/web/ProductCatalogTest.java index 44776ca..abea7ff 100644 --- a/src/test/java/com/itsthevine/web/ProductCatalogTest.java +++ b/src/test/java/com/itsthevine/web/ProductCatalogTest.java @@ -29,6 +29,12 @@ class ProductCatalogTest { registry.add("spring.datasource.username", POSTGRES::getUsername); registry.add("spring.datasource.password", POSTGRES::getPassword); registry.add("site.assets.base-url", () -> "https://s3.example.test/itsthevine"); + // The contact starter refuses to start on a blank recipient, and this app has a + // ContactController, so the context needs one even to test the catalogue. + registry.add("platform.contact.to", () -> "test@example.com"); + registry.add("platform.contact.from", () -> "noreply@example.com"); + registry.add("platform.storage.access-key", () -> "test"); + registry.add("platform.storage.secret-key", () -> "test"); } @Autowired