updates
All checks were successful
Build Org Backend / build (push) Successful in 17s

This commit is contained in:
2026-08-20 14:20:13 +01:00
parent cdbdcc5832
commit 767b22f945
30 changed files with 426 additions and 0 deletions

2
data/resource-loader/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
library.json
thumbnails/

View File

View File

View File

0
src/main/java/org/zaine/app/notes/domain/Note.java Normal file → Executable file
View File

View File

@@ -0,0 +1,11 @@
package org.zaine.app.resource;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.PRECONDITION_FAILED)
public class ResourceLoaderConflictException extends RuntimeException {
public ResourceLoaderConflictException() {
super("The Resource Loader library changed on another device.");
}
}

View File

@@ -0,0 +1,65 @@
package org.zaine.app.resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/resource-loader")
@Tag(name = "Resource Loader", description = "Cross-device Resource Loader metadata and thumbnails")
public class ResourceLoaderController {
private final ResourceLoaderService service;
public ResourceLoaderController(ResourceLoaderService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<JsonNode> loadLibrary() {
ResourceLoaderService.LibrarySnapshot snapshot = service.loadLibrary();
return ResponseEntity.ok()
.eTag(snapshot.etag())
.cacheControl(CacheControl.noStore())
.body(snapshot.library());
}
@PutMapping
public ResponseEntity<JsonNode> saveLibrary(
@RequestBody JsonNode library,
@RequestHeader(value = "If-Match", required = false) String ifMatch) {
ResourceLoaderService.LibrarySnapshot snapshot = service.saveLibrary(library, ifMatch);
return ResponseEntity.ok()
.eTag(snapshot.etag())
.cacheControl(CacheControl.noStore())
.body(snapshot.library());
}
@GetMapping("/thumbnails/{id}")
public ResponseEntity<byte[]> loadThumbnail(@PathVariable String id) {
ResourceLoaderService.ThumbnailSnapshot thumbnail = service.loadThumbnail(id);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(thumbnail.contentType()))
.cacheControl(CacheControl.noStore())
.body(thumbnail.bytes());
}
@PutMapping("/thumbnails/{id}")
public ResponseEntity<Void> saveThumbnail(
@PathVariable String id,
@RequestHeader("Content-Type") String contentType,
@RequestBody byte[] bytes) {
service.saveThumbnail(id, contentType, bytes);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,185 @@
package org.zaine.app.resource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.zaine.app.common.application.ApplicationException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@Service
public class ResourceLoaderService {
private static final String DEFAULT_STORAGE_DIRECTORY =
"/home/zaine/master-folder/org-platform/org_backend/data/resource-loader";
private static final Set<String> THUMBNAIL_TYPES = Set.of("image/jpeg", "image/png", "image/webp");
private static final long MAX_LIBRARY_BYTES = 10L * 1024 * 1024;
private static final long MAX_THUMBNAIL_BYTES = 5L * 1024 * 1024;
private final ObjectMapper objectMapper;
private final Path storageDirectory;
private final Path libraryPath;
private final Path thumbnailDirectory;
public ResourceLoaderService(
ObjectMapper objectMapper,
@Value("${resource-loader.storage-dir:" + DEFAULT_STORAGE_DIRECTORY + "}") String storageDirectory) {
this.objectMapper = objectMapper;
this.storageDirectory = Path.of(storageDirectory);
this.libraryPath = this.storageDirectory.resolve("library.json");
this.thumbnailDirectory = this.storageDirectory.resolve("thumbnails");
}
public synchronized LibrarySnapshot loadLibrary() {
if (!Files.isRegularFile(libraryPath)) {
throw ApplicationException.notFound("Resource Loader library has not been initialised.");
}
try {
byte[] bytes = Files.readAllBytes(libraryPath);
JsonNode library = objectMapper.readTree(bytes);
validateLibrary(library);
return new LibrarySnapshot(library, etag(bytes));
} catch (ApplicationException e) {
throw e;
} catch (IOException e) {
throw ApplicationException.failure("Failed to read the Resource Loader library.");
}
}
public synchronized LibrarySnapshot saveLibrary(JsonNode library, String ifMatch) {
validateLibrary(library);
try {
if (Files.isRegularFile(libraryPath) && ifMatch != null && !ifMatch.isBlank()) {
String currentEtag = etag(Files.readAllBytes(libraryPath));
if (!currentEtag.equals(ifMatch.trim())) {
throw new ResourceLoaderConflictException();
}
}
byte[] bytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(library);
if (bytes.length > MAX_LIBRARY_BYTES) {
throw ApplicationException.payloadTooLarge("Resource Loader metadata exceeds 10 MB.");
}
Files.createDirectories(storageDirectory);
atomicWrite(libraryPath, bytes);
return new LibrarySnapshot(objectMapper.readTree(bytes), etag(bytes));
} catch (ResourceLoaderConflictException | ApplicationException e) {
throw e;
} catch (IOException e) {
throw ApplicationException.failure("Failed to save the Resource Loader library.");
}
}
public synchronized void saveThumbnail(String id, String contentType, byte[] bytes) {
validateId(id);
String normalisedType = contentType == null ? "" : contentType.split(";", 2)[0].trim().toLowerCase();
if (!THUMBNAIL_TYPES.contains(normalisedType)) {
throw ApplicationException.badRequest("Thumbnail must be JPEG, PNG, or WebP.");
}
if (bytes == null || bytes.length == 0) {
throw ApplicationException.badRequest("Thumbnail is empty.");
}
if (bytes.length > MAX_THUMBNAIL_BYTES) {
throw ApplicationException.payloadTooLarge("Thumbnail exceeds 5 MB.");
}
try {
Files.createDirectories(thumbnailDirectory);
atomicWrite(thumbnailPath(id), bytes);
atomicWrite(thumbnailTypePath(id), normalisedType.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw ApplicationException.failure("Failed to save the Resource Loader thumbnail.");
}
}
public ThumbnailSnapshot loadThumbnail(String id) {
validateId(id);
Path image = thumbnailPath(id);
Path type = thumbnailTypePath(id);
if (!Files.isRegularFile(image) || !Files.isRegularFile(type)) {
throw ApplicationException.notFound("Resource Loader thumbnail was not found.");
}
try {
return new ThumbnailSnapshot(Files.readAllBytes(image), Files.readString(type).trim());
} catch (IOException e) {
throw ApplicationException.failure("Failed to read the Resource Loader thumbnail.");
}
}
private void validateLibrary(JsonNode library) {
if (library == null || !library.isObject()) {
throw ApplicationException.badRequest("Resource Loader library must be a JSON object.");
}
if (!library.path("schemaVersion").isInt() || library.path("schemaVersion").intValue() != 1) {
throw ApplicationException.badRequest("Unsupported Resource Loader schemaVersion.");
}
JsonNode vaults = library.path("vaults");
if (!vaults.isArray() || vaults.isEmpty()) {
throw ApplicationException.badRequest("Resource Loader library must contain at least one vault.");
}
for (JsonNode vault : vaults) {
if (!nonBlank(vault, "id") || !nonBlank(vault, "name") || !vault.path("folders").isArray()
|| !vault.path("resources").isArray()) {
throw ApplicationException.badRequest("Every vault requires an id, name, folders, and resources.");
}
for (JsonNode resource : vault.path("resources")) {
if (!nonBlank(resource, "id") || !nonBlank(resource, "title") || !resource.path("sessions").isArray()) {
throw ApplicationException.badRequest("Every resource requires an id, title, and sessions array.");
}
}
}
}
private static boolean nonBlank(JsonNode node, String field) {
return node.path(field).isTextual() && !node.path(field).textValue().isBlank();
}
private static void validateId(String id) {
if (id == null || !id.matches("[A-Za-z0-9_-]{1,128}")) {
throw ApplicationException.badRequest("Invalid Resource Loader thumbnail id.");
}
}
private Path thumbnailPath(String id) {
return thumbnailDirectory.resolve(id + ".bin");
}
private Path thumbnailTypePath(String id) {
return thumbnailDirectory.resolve(id + ".type");
}
private static void atomicWrite(Path target, byte[] bytes) throws IOException {
Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".tmp");
try {
Files.write(temporary, bytes);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static String etag(byte[] bytes) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
return "\"" + HexFormat.of().formatHex(digest) + "\"";
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
public record LibrarySnapshot(JsonNode library, String etag) {}
public record ThumbnailSnapshot(byte[] bytes, String contentType) {}
}

View File

View File

@@ -0,0 +1,76 @@
package org.zaine.app.resource;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
class ResourceLoaderControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private ResourceLoaderService service;
private MockMvc mvc;
@BeforeEach
void setUp() {
service = mock(ResourceLoaderService.class);
mvc = MockMvcBuilders.standaloneSetup(new ResourceLoaderController(service)).build();
}
@Test
void returnsTheLibraryAndEtag() throws Exception {
JsonNode library = library();
when(service.loadLibrary()).thenReturn(new ResourceLoaderService.LibrarySnapshot(library, "\"revision-one\""));
mvc.perform(get("/api/resource-loader"))
.andExpect(status().isOk())
.andExpect(header().string("ETag", "\"revision-one\""))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(jsonPath("$.schemaVersion").value(1));
}
@Test
void passesIfMatchWhenSaving() throws Exception {
JsonNode library = library();
when(service.saveLibrary(any(JsonNode.class), eq("\"revision-one\"")))
.thenReturn(new ResourceLoaderService.LibrarySnapshot(library, "\"revision-two\""));
mvc.perform(put("/api/resource-loader")
.contentType(MediaType.APPLICATION_JSON)
.header("If-Match", "\"revision-one\"")
.content(library.toString()))
.andExpect(status().isOk())
.andExpect(header().string("ETag", "\"revision-two\""));
verify(service).saveLibrary(any(JsonNode.class), eq("\"revision-one\""));
}
@Test
void preventsBrowserCachingOfThumbnails() throws Exception {
when(service.loadThumbnail("cover-one"))
.thenReturn(new ResourceLoaderService.ThumbnailSnapshot(
new byte[] { 1, 2, 3 }, "image/jpeg"));
mvc.perform(get("/api/resource-loader/thumbnails/cover-one"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"));
}
private JsonNode library() throws Exception {
return objectMapper.readTree("{\"schemaVersion\":1,\"vaults\":[]}");
}
}

View File

@@ -0,0 +1,87 @@
package org.zaine.app.resource;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.zaine.app.common.application.ApplicationException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
class ResourceLoaderServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@TempDir
Path tempDir;
@Test
void savesAndLoadsLibraryWithAnEtag() throws Exception {
ResourceLoaderService service = service();
JsonNode library = library("First title");
ResourceLoaderService.LibrarySnapshot saved = service.saveLibrary(library, null);
ResourceLoaderService.LibrarySnapshot loaded = service.loadLibrary();
assertEquals("First title", loaded.library().path("vaults").get(0).path("resources").get(0).path("title").asText());
assertEquals(saved.etag(), loaded.etag());
}
@Test
void rejectsAStaleEtag() throws Exception {
ResourceLoaderService service = service();
ResourceLoaderService.LibrarySnapshot first = service.saveLibrary(library("First"), null);
ResourceLoaderService.LibrarySnapshot second = service.saveLibrary(library("Second"), first.etag());
assertNotEquals(first.etag(), second.etag());
assertThrows(ResourceLoaderConflictException.class, () -> service.saveLibrary(library("Stale"), first.etag()));
}
@Test
void rejectsInvalidLibraries() throws Exception {
ResourceLoaderService service = service();
JsonNode invalid = objectMapper.readTree("{\"schemaVersion\":2,\"vaults\":[]}");
assertThrows(ApplicationException.class, () -> service.saveLibrary(invalid, null));
}
@Test
void storesThumbnailBytesAndType() {
ResourceLoaderService service = service();
byte[] bytes = new byte[] { 1, 2, 3, 4 };
service.saveThumbnail("thumbnail-id", "image/jpeg", bytes);
ResourceLoaderService.ThumbnailSnapshot loaded = service.loadThumbnail("thumbnail-id");
assertArrayEquals(bytes, loaded.bytes());
assertEquals("image/jpeg", loaded.contentType());
}
private ResourceLoaderService service() {
return new ResourceLoaderService(objectMapper, tempDir.toString());
}
private JsonNode library(String title) throws Exception {
return objectMapper.readTree("""
{
"schemaVersion": 1,
"vaults": [{
"id": "vault-work",
"name": "Work",
"folders": [],
"resources": [{
"id": "resource-1",
"title": "%s",
"type": "book",
"format": "physical",
"status": "backlog",
"sessions": []
}]
}]
}
""".formatted(title));
}
}

View File

View File

View File

View File