diff --git a/.gitignore b/.gitignore
index aec25a57b..96e7e975c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,7 @@ Thumbs.db
.settings/*
# IntelliJ stuff
-/.idea/*
+/.idea
/*.iml
/*.ipr
/*.iws
diff --git a/CHANGES.md b/CHANGES.md
index 5496b8600..e3ac2baaa 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,39 @@
# Change Log
+## 6.0
+
+### Endpoints
+
+* Image and information responses include a `Last-Modified` header when
+ possible.
+* The health endpoint is enabled via `endpoint.health.enabled` rather than
+ `endpoint.api.enabled`.
+* Added an HTTP API method to purge all infos from the derivative cache.
+* Added a configuration option to automatically purge source-cached images
+ whose format cannot be inferred.
+
+### Sources
+
+* HttpSource supports a client HTTP proxy. (Thanks to @mightymax and
+ @mlindeman)
+* HttpSource can be configured to send a ranged GET request instead of a HEAD
+ request, enabling it to work with pre-signed URLs that do not allow HEAD
+ requests.
+* S3Source supports multiple endpoints when using ScriptLookupStrategy.
+
+### Caches
+
+* S3Cache uses multipart uploads, which reduces memory usage when caching
+ derivatives larger than 5 MB.
+
+### Delegate Script
+
+* The delegate script pathname can be set using the
+ `-Dcantaloupe.delegate_script` VM argument, which takes precedence over the
+ `delegate_script.pathname` configuration key.
+* The delegate script's `metadata` context key contains a new field,
+ `xmp_elements`, that provides a high-level key-value view of the XMP data.
+
## 5.0.6
* IIIF information endpoints always return JSON in HTTP 4xx responses.
diff --git a/UPGRADING.md b/UPGRADING.md
index db1e9a7e3..3e38801c7 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -3,6 +3,20 @@
If you are skipping versions, work through these sections backwards from your
current version.
+## 5.0 → 6.0
+
+1. Add the following keys from the sample configuration:
+ * `endpoint.health.enabled`
+ * `HttpSource.proxy.http.host`
+ * `HttpSource.proxy.http.port`
+ * `HttpSource.BasicLookupStrategy.send_head_requests`
+ * `processor.purge_incompatible_from_source_cache`
+2. Add the following methods from the sample delegate script:
+ * `jdbcsource_last_modified()`
+3. If you are using a Java delegate, add the following method to your delegate
+ class:
+ * `getJDBCSourceLastModified()`
+
## 4.1.x → 5.0
1. Note that the application is now packaged as a JAR file which can no longer
diff --git a/cantaloupe.properties.sample b/cantaloupe.properties.sample
index 6de429746..e205ad760 100644
--- a/cantaloupe.properties.sample
+++ b/cantaloupe.properties.sample
@@ -88,6 +88,8 @@ delegate_script.enabled = false
# !! This can be an absolute path, or a filename; if only a filename is
# specified, it will be searched for in the same folder as this file, and
# then the current working directory.
+# The delegate script pathname can also be set using the
+# -Dcantaloupe.delegate_script VM argument, which overrides this value.
delegate_script.pathname = delegates.rb
###########################################################################
@@ -126,6 +128,9 @@ endpoint.api.enabled = false
endpoint.api.username =
endpoint.api.secret =
+# Enables the health check endpoint, at /health.
+endpoint.health.enabled = true
+
# If true, sources and caches will be checked, resulting in a more robust
# but slower health check. Set this to false if these services already have
# their own health checks.
@@ -170,6 +175,10 @@ HttpSource.allow_insecure = false
# Request timeout in seconds.
HttpSource.request_timeout =
+# !! Client HTTP proxy.
+HttpSource.proxy.http.host =
+HttpSource.proxy.http.port =
+
# Tells HttpSource how to look up resources. Allowed values are
# `BasicLookupStrategy` and `ScriptLookupStrategy`. ScriptLookupStrategy
# uses a delegate method for dynamic lookups; see the user manual.
@@ -187,6 +196,12 @@ HttpSource.BasicLookupStrategy.url_suffix =
HttpSource.BasicLookupStrategy.auth.basic.username =
HttpSource.BasicLookupStrategy.auth.basic.secret =
+# Before an image is retrieved, a preliminary request is sent to check
+# various characteristics. Typically this is a HEAD request, but some
+# resources, such as those using pre-signed URLs, may not support HEAD
+# requests. This key enables a ranged GET request to be sent instead.
+HttpSource.BasicLookupStrategy.send_head_requests = true
+
# Read data in chunks when it may be more efficient. (This also may end up
# being less efficient, depending on many variables; see the user manual.)
HttpSource.chunking.enabled = true
@@ -359,6 +374,10 @@ processor.stream_retrieval_strategy = StreamStrategy
# * `AbortStrategy` causes the request to fail.
processor.fallback_retrieval_strategy = DownloadStrategy
+# If true, images stored in the source cache for which no format can be
+# inferred will be purged.
+processor.purge_incompatible_from_source_cache = false
+
# Resolution of vector rasterization (of e.g. PDFs) at a scale of 1.
processor.dpi = 150
diff --git a/delegates.rb.sample b/delegates.rb.sample
index 6d39f0b56..d56cf1e9b 100644
--- a/delegates.rb.sample
+++ b/delegates.rb.sample
@@ -221,10 +221,17 @@ class CustomDelegate
#
# 1. String URI
# 2. Hash with the following keys:
- # * `uri` [String] (required)
- # * `username` [String] For HTTP Basic authentication (optional).
- # * `secret` [String] For HTTP Basic authentication (optional).
- # * `headers` [Hash] Hash of request headers (optional).
+ # * `uri` [String] (required)
+ # * `username` [String] For HTTP Basic authentication
+ # (optional).
+ # * `secret` [String] For HTTP Basic authentication
+ # (optional).
+ # * `headers` [Hash] Hash of request headers
+ # (optional).
+ # * `send_head_request` [Boolean] Optional; defaults to `true`. See the
+ # documentation of the
+ # `HttpSource.BasicLookupStrategy.send_head_requests`
+ # configuration key.
# 3. nil if not found.
#
# N.B.: this method should not try to perform authorization. `authorize()`
@@ -241,18 +248,33 @@ class CustomDelegate
# should be used instead.
#
# @param options [Hash] Empty hash.
- # @return [String] Identifier of the image corresponding to the given
- # identifier in the database.
+ # @return [String, nil] Database identifier of the image corresponding to the
+ # identifier in the context, or nil if not found.
#
def jdbcsource_database_identifier(options = {})
end
+ ##
+ # Returns either the last-modified timestamp of an image in ISO 8601 format,
+ # or an SQL statement that can be used to retrieve it from a `TIMESTAMP`-type
+ # column in the database. In the latter case, the "SELECT" and "FROM" clauses
+ # should be in uppercase in order to be autodetected.
+ #
+ # Implementing this method is optional, but may be necessary for certain
+ # features (like `Last-Modified` response headers) to work.
+ #
+ # @param options [Hash] Empty hash.
+ # @return [String, nil]
+ #
+ def jdbcsource_last_modified(options = {})
+ end
+
##
# Returns either the media (MIME) type of an image, or an SQL statement that
- # can be used to retrieve it, if it is stored in the database. In the latter
- # case, the "SELECT" and "FROM" clauses should be in uppercase in order to
- # be autodetected. If nil is returned, the media type will be inferred some
- # other way, such as by identifier extension or magic bytes.
+ # can be used to retrieve it from a `CHAR`-type column in the database. In
+ # the latter case, the "SELECT" and "FROM" clauses should be in uppercase in
+ # order to be autodetected. If nil is returned, the media type will be
+ # inferred some other way, such as by identifier extension or magic bytes.
#
# @param options [Hash] Empty hash.
# @return [String, nil]
@@ -273,8 +295,11 @@ class CustomDelegate
# should be used instead.
#
# @param options [Hash] Empty hash.
- # @return [Hash,nil] Hash containing `bucket` and `key` keys;
- # or nil if not found.
+ # @return [Hash,nil] Hash containing `bucket` and `key` keys.
+ # It may also contain an `endpoint` key, indicating that the endpoint
+ # is different from the one set in the configuration. In that case,
+ # it may also contain `region`, `access_key_id`, and/or
+ # `secret_access_key` keys.
#
def s3source_object_info(options = {})
end
@@ -356,7 +381,14 @@ class CustomDelegate
# "Field2Name": value
# ],
# "xmp_string": "...",
- # "xmp_model": https://jena.apache.org/documentation/javadoc/jena/org/apache/jena/rdf/model/Model.html
+ # "xmp_model": See https://jena.apache.org/documentation/javadoc/jena/org/apache/jena/rdf/model/Model.html,
+ # "xmp_elements": {
+ # "Field1Name": "value",
+ # "Field2Name": [
+ # "value1",
+ # "value2"
+ # ]
+ # },
# "native": {
# # structure varies
# }
@@ -366,10 +398,13 @@ class CustomDelegate
# * The `exif` key refers to embedded EXIF data. This also includes IFD0
# metadata from source TIFFs, whether or not an EXIF IFD is present.
# * The `iptc` key refers to embedded IPTC IIM data.
- # * The `xmp_string` key refers to raw embedded XMP data, which may or may
- # not contain EXIF and/or IPTC information.
+ # * The `xmp_string` key refers to raw embedded XMP data.
# * The `xmp_model` key contains a Jena Model object pre-loaded with the
# contents of `xmp_string`.
+ # * The `xmp_elements` key contains a view of the embedded XMP data as key-
+ # value pairs. This is convenient to use, but may not work correctly with
+ # all XMP fields--in particular, those that cannot be expressed as
+ # key-value pairs.
# * The `native` key refers to format-specific metadata.
#
# Any combination of the above keys may be present or missing depending on
diff --git a/pom.xml b/pom.xml
index 28b9dc726..94f60f209 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
edu.illinois.library.cantaloupecantaloupejar
- 5.0.5
+ 6.0-SNAPSHOTCantaloupehttps://cantaloupe-project.github.io/2015
@@ -17,6 +17,7 @@
UTF-82.21.42.15.2
+
9.4.53.v202310099.4.3.03.0.0-M5
@@ -423,8 +424,9 @@
randomfalse
-
- --add-opens java.desktop/sun.awt.image=ALL-UNNAMED
+
+
+ --add-opens java.desktop/sun.awt.image=ALL-UNNAMED --illegal-access=permit
diff --git a/src/main/java/edu/illinois/library/cantaloupe/ApplicationContextListener.java b/src/main/java/edu/illinois/library/cantaloupe/ApplicationContextListener.java
index 9db7b83e9..0071991a2 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/ApplicationContextListener.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/ApplicationContextListener.java
@@ -13,9 +13,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import jakarta.servlet.ServletContextEvent;
+import jakarta.servlet.ServletContextListener;
import javax.script.ScriptEngineManager;
-import javax.servlet.ServletContextEvent;
-import javax.servlet.ServletContextListener;
import java.util.stream.Collectors;
/**
diff --git a/src/main/java/edu/illinois/library/cantaloupe/ApplicationServer.java b/src/main/java/edu/illinois/library/cantaloupe/ApplicationServer.java
index a048842a0..a35c07c7f 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/ApplicationServer.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/ApplicationServer.java
@@ -6,6 +6,7 @@
import edu.illinois.library.cantaloupe.resource.FileServlet;
import edu.illinois.library.cantaloupe.resource.HandlerServlet;
import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
+import org.eclipse.jetty.http.UriCompliance;
import org.eclipse.jetty.http2.HTTP2Cipher;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
@@ -265,7 +266,8 @@ public void start() throws Exception {
// HTTP/2.
if (isHTTPEnabled()) {
HttpConfiguration config = new HttpConfiguration();
- HttpConnectionFactory http1 = new HttpConnectionFactory();
+ config.setUriCompliance(UriCompliance.LEGACY);
+ HttpConnectionFactory http1 = new HttpConnectionFactory(config);
HTTP2CServerConnectionFactory http2 =
new HTTP2CServerConnectionFactory(config);
@@ -280,11 +282,12 @@ public void start() throws Exception {
// Initialize the HTTPS server.
if (isHTTPSEnabled()) {
HttpConfiguration config = new HttpConfiguration();
+ config.setUriCompliance(UriCompliance.LEGACY);
config.setSecureScheme("https");
config.setSecurePort(getHTTPSPort());
config.addCustomizer(new SecureRequestCustomizer());
- final SslContextFactory contextFactory = new SslContextFactory.Server();
+ final SslContextFactory.Server contextFactory = new SslContextFactory.Server();
contextFactory.setKeyStorePath(getHTTPSKeyStorePath());
if (getHTTPSKeyStorePassword() != null) {
contextFactory.setKeyStorePassword(getHTTPSKeyStorePassword());
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/AzureStorageCache.java b/src/main/java/edu/illinois/library/cantaloupe/cache/AzureStorageCache.java
index 22bd99001..2d212dd6b 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/AzureStorageCache.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/AzureStorageCache.java
@@ -67,7 +67,7 @@ private static class CustomBlobOutputStream
/**
* Constructor for an instance that writes into the given temporary
- * blob. Upon closure, if the stream is {@link #isCompletelyWritten()
+ * blob. Upon closure, if the stream is {@link #isComplete()
* completely written}, the temporary blob is copied into place and
* deleted. Otherwise, the temporary blob is deleted.
*
@@ -96,7 +96,7 @@ public void close() throws IOException {
blobOutputStream.flush();
blobOutputStream.close();
if (container != null) {
- if (isCompletelyWritten()) {
+ if (isComplete()) {
// Copy the temporary blob into place.
CloudBlockBlob destBlob =
container.getBlockBlobReference(blobKey);
@@ -144,6 +144,8 @@ public void write(byte[] b, int off, int len) throws IOException {
private static final Logger LOGGER =
LoggerFactory.getLogger(AzureStorageCache.class);
+ private static final String INFO_EXTENSION = ".json";
+
private static CloudBlobClient client;
/**
@@ -197,7 +199,7 @@ private static Instant getEarliestValidInstant() {
@Override
public Optional getInfo(Identifier identifier) throws IOException {
- final String containerName = getContainerName();
+ final String containerName = getContainerName();
final CloudBlobClient client = getClientInstance();
try {
@@ -211,6 +213,12 @@ public Optional getInfo(Identifier identifier) throws IOException {
if (isValid(blob)) {
try (InputStream is = blob.openInputStream()) {
Info info = Info.fromJSON(is);
+ // Populate the serialization timestamp if it is not
+ // already, as suggested by the method contract.
+ if (info.getSerializationTimestamp() == null) {
+ info.setSerializationTimestamp(
+ blob.getProperties().getLastModified().toInstant());
+ }
LOGGER.debug("getInfo(): read {} from container {} in {}",
objectKey, containerName, watch);
return Optional.of(info);
@@ -290,7 +298,7 @@ public InputStream newDerivativeImageInputStream(OperationList opList)
*/
String getObjectKey(Identifier identifier) {
return getObjectKeyPrefix() + "info/" +
- StringUtils.md5(identifier.toString()) + ".json";
+ StringUtils.md5(identifier.toString()) + INFO_EXTENSION;
}
/**
@@ -390,6 +398,32 @@ private void purgeAsync(CloudBlob blob) {
});
}
+ @Override
+ public void purgeInfos() throws IOException {
+ final String containerName = getContainerName();
+ final CloudBlobClient client = getClientInstance();
+ try {
+ final CloudBlobContainer container =
+ client.getContainerReference(containerName);
+ int count = 0, deletedCount = 0;
+ for (ListBlobItem item : container.listBlobs(getObjectKeyPrefix(), true)) {
+ if (item instanceof CloudBlob) {
+ CloudBlob blob = (CloudBlob) item;
+ count++;
+ if (blob.getName().endsWith(INFO_EXTENSION)) {
+ if (blob.deleteIfExists()) {
+ deletedCount++;
+ }
+ }
+ }
+ }
+ LOGGER.debug("purgeInfos(): deleted {} of {} items",
+ deletedCount, count);
+ } catch (URISyntaxException | StorageException e) {
+ throw new IOException(e.getMessage(), e);
+ }
+ }
+
@Override
public void purgeInvalid() throws IOException {
final String containerName = getContainerName();
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/CacheFacade.java b/src/main/java/edu/illinois/library/cantaloupe/cache/CacheFacade.java
index 78ff159e5..9bdbfcf92 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/CacheFacade.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/CacheFacade.java
@@ -186,6 +186,17 @@ public void purge(OperationList opList) throws IOException {
}
}
+ /**
+ * @see DerivativeCache#purgeInfos()
+ * @since 6.0
+ */
+ public void purgeInfos() throws IOException {
+ Optional optCache = getDerivativeCache();
+ if (optCache.isPresent()) {
+ optCache.get().purgeInfos();
+ }
+ }
+
/**
* @see Cache#purgeInvalid
*/
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/CompletableOutputStream.java b/src/main/java/edu/illinois/library/cantaloupe/cache/CompletableOutputStream.java
index a0d9b3f98..1f2a0e70a 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/CompletableOutputStream.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/CompletableOutputStream.java
@@ -10,14 +10,14 @@
*/
public abstract class CompletableOutputStream extends OutputStream {
- private boolean isCompletelyWritten;
+ private boolean isComplete;
- public boolean isCompletelyWritten() {
- return isCompletelyWritten;
+ public boolean isComplete() {
+ return isComplete;
}
- public void setCompletelyWritten(boolean isCompletelyWritten) {
- this.isCompletelyWritten = isCompletelyWritten;
+ public void setComplete(boolean isCompletelyWritten) {
+ this.isComplete = isCompletelyWritten;
}
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/DerivativeCache.java b/src/main/java/edu/illinois/library/cantaloupe/cache/DerivativeCache.java
index 364baaf71..f7cdd0032 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/DerivativeCache.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/DerivativeCache.java
@@ -20,6 +20,13 @@ public interface DerivativeCache extends Cache {
*
Reads the cached image information corresponding to the given
* identifier.
*
+ *
If the returned instance's {@link Info#getSerializationTimestamp()
+ * serialization timestamp} is {@code null} (which it will be for {@link
+ * Info.Serialization serialization versions} earlier than {@link
+ * Info.Serialization#VERSION_5}), implementations should try to
+ * populate it with the last-modified time of the cached resource, if
+ * possible.
+ *
*
If invalid image information exists in the cache, implementations
* should delete it—ideally asynchronously.
The {@link CompletableOutputStream#close()} method of the returned
* instance must check the return value of {@link
- * CompletableOutputStream#isCompletelyWritten()} before committing data
+ * CompletableOutputStream#isComplete()} before committing data
* to the cache. If it returns {@code false}, any written data should be
* discarded.
*
@@ -80,6 +87,16 @@ CompletableOutputStream newDerivativeImageOutputStream(OperationList opList)
*/
void purge(OperationList opList) throws IOException;
+ /**
+ * Deletes all cached infos.
+ *
+ * @throws IOException upon fatal error. Implementations should do the
+ * best they can to complete the operation and swallow and log
+ * non-fatal errors.
+ * @since 5.0
+ */
+ void purgeInfos() throws IOException;
+
/**
*
Synchronously adds image information to the cache.
*
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/FilesystemCache.java b/src/main/java/edu/illinois/library/cantaloupe/cache/FilesystemCache.java
index 9b7bc92e5..ab0dc383a 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/FilesystemCache.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/FilesystemCache.java
@@ -17,7 +17,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileVisitOption;
import java.nio.file.Files;
@@ -126,8 +126,8 @@ private static class ConcurrentFileOutputStream
private boolean isClosed = false;
private final Object lock;
private final Path tempFile;
- private T toRemove;
- private OutputStream wrappedOutputStream;
+ private final T toRemove;
+ private final OutputStream wrappedOutputStream;
/**
* @param tempFile Pathname of the temp file to write to.
@@ -180,7 +180,7 @@ public void close() {
// If the written file is complete, move it into place.
// Otherwise, delete it.
- if (isCompletelyWritten()) {
+ if (isComplete()) {
CFOS_LOGGER.debug("close(): moving {} to {}",
tempFile, destinationFile);
Files.move(tempFile, destinationFile,
@@ -237,7 +237,7 @@ public void write(byte[] b, int off, int len) throws IOException {
private static final String INFO_FOLDER = "info";
private static final String SOURCE_IMAGE_FOLDER = "source";
- private static final String INFO_EXTENSION = ".json";
+ static final String INFO_EXTENSION = ".json";
private static final String TEMP_EXTENSION = ".tmp";
/**
@@ -316,7 +316,7 @@ static String hashedPathFragment(String uniqueString) {
try {
final MessageDigest digest =
MessageDigest.getInstance(HASH_ALGORITHM);
- digest.update(uniqueString.getBytes(Charset.forName("UTF8")));
+ digest.update(uniqueString.getBytes(StandardCharsets.UTF_8));
final String sum = Hex.encodeHexString(digest.digest());
final Configuration config = Configuration.getInstance();
@@ -526,7 +526,14 @@ public Optional getInfo(Identifier identifier) throws IOException {
final Path cacheFile = infoFile(identifier);
if (!isExpired(cacheFile)) {
LOGGER.debug("getInfo(): hit: {}", cacheFile);
- return Optional.of(Info.fromJSON(cacheFile));
+ Info info = Info.fromJSON(cacheFile);
+ // Populate the serialization timestamp if it is not
+ // already, as suggested by the method contract.
+ if (info.getSerializationTimestamp() == null) {
+ info.setSerializationTimestamp(
+ Files.getLastModifiedTime(cacheFile).toInstant());
+ }
+ return Optional.of(info);
} else {
purgeAsync(cacheFile);
}
@@ -627,7 +634,7 @@ identifier, sourceImageTempFile(identifier),
// work with newDerivativeImageOutputStream(). But this method does not
// need that extra functionality, so setting it as completely written
// here makes it behave like an ordinary OutputStream.
- os.setCompletelyWritten(true);
+ os.setComplete(true);
return os;
}
@@ -839,6 +846,44 @@ private void purgeAsync(final Path path) {
});
}
+ @Override
+ public void purgeInfos() throws IOException {
+ if (isGlobalPurgeInProgress.get()) {
+ LOGGER.debug("purgeInfos() called with a purge in progress. Aborting.");
+ return;
+ }
+ synchronized (infoPurgeLock) {
+ while (!infosBeingPurged.isEmpty()) {
+ try {
+ LOGGER.debug("purgeInfos(): waiting...");
+ infoPurgeLock.wait();
+ } catch (InterruptedException e) {
+ break;
+ }
+ }
+ }
+
+ try {
+ isGlobalPurgeInProgress.set(true);
+
+ final InfoFileVisitor visitor = new InfoFileVisitor();
+
+ LOGGER.debug("purgeInfos(): starting...");
+ Files.walkFileTree(rootPath(),
+ EnumSet.of(FileVisitOption.FOLLOW_LINKS),
+ Integer.MAX_VALUE,
+ visitor);
+ LOGGER.debug("purgeInfos(): purged {} info(s) totaling {} bytes",
+ visitor.getDeletedFileCount(),
+ visitor.getDeletedFileSize());
+ } finally {
+ isGlobalPurgeInProgress.set(false);
+ synchronized (infoPurgeLock) {
+ infoPurgeLock.notifyAll();
+ }
+ }
+ }
+
/**
*
Crawls the image directory, deleting all expired files within it
* (temporary or not), and then does the same in the info directory.
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/HeapCache.java b/src/main/java/edu/illinois/library/cantaloupe/cache/HeapCache.java
index 414986b70..f870565e0 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/HeapCache.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/HeapCache.java
@@ -184,7 +184,7 @@ private class HeapCacheOutputStream extends CompletableOutputStream {
@Override
public void close() throws IOException {
LOGGER.debug("Closing stream for {}", opList);
- if (isCompletelyWritten()) {
+ if (isComplete()) {
Key key = itemKey(opList);
Item item = new Item(wrappedStream.toByteArray());
cache.put(key, item);
@@ -233,7 +233,7 @@ public void run() {
if (workerShouldWork.get()) {
try {
purgeExcess();
- logger.debug("Cache size: {} items ({} bytes)",
+ logger.trace("Cache size: {} items ({} bytes)",
size(), getByteSize());
Thread.sleep(INTERVAL_SECONDS * 1000);
} catch (ConfigurationException e) {
@@ -590,6 +590,11 @@ void purgeExcess() throws ConfigurationException {
}
}
+ @Override
+ public void purgeInfos() {
+ cache.entrySet().removeIf(entry -> entry.getKey().opList == null);
+ }
+
/**
* Does nothing, as items in this cache never expire.
*/
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/InfoFileVisitor.java b/src/main/java/edu/illinois/library/cantaloupe/cache/InfoFileVisitor.java
new file mode 100644
index 000000000..d8d91f80f
--- /dev/null
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/InfoFileVisitor.java
@@ -0,0 +1,55 @@
+package edu.illinois.library.cantaloupe.cache;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+
+/**
+ * Used by {@link Files#walkFileTree} to delete all info files within a
+ * directory.
+ */
+class InfoFileVisitor extends SimpleFileVisitor {
+
+ private static final Logger LOGGER =
+ LoggerFactory.getLogger(InfoFileVisitor.class);
+
+ private long deletedFileCount = 0;
+ private long deletedFileSize = 0;
+
+ long getDeletedFileCount() {
+ return deletedFileCount;
+ }
+
+ long getDeletedFileSize() {
+ return deletedFileSize;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path path,
+ BasicFileAttributes attrs) {
+ try {
+ if (path.toString().endsWith(FilesystemCache.INFO_EXTENSION)) {
+ long size = Files.size(path);
+ Files.delete(path);
+ deletedFileCount++;
+ deletedFileSize += size;
+ }
+ } catch (IOException e) {
+ LOGGER.warn(e.getMessage(), e);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(Path file, IOException e) {
+ LOGGER.warn("visitFileFailed(): {}", e.getMessage());
+ return FileVisitResult.CONTINUE;
+ }
+
+}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/JdbcCache.java b/src/main/java/edu/illinois/library/cantaloupe/cache/JdbcCache.java
index 41ff213c2..ee09c2997 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/JdbcCache.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/JdbcCache.java
@@ -48,7 +48,7 @@ class JdbcCache implements DerivativeCache {
/**
* Wraps a {@link Blob} OutputStream, for writing an image to a BLOB.
* The constructor creates a transaction that is committed on close if the
- * stream is {@link CompletableOutputStream#isCompletelyWritten()
+ * stream is {@link CompletableOutputStream#isComplete()
* completely written}.
*/
private class ImageBlobOutputStream extends CompletableOutputStream {
@@ -71,7 +71,16 @@ private class ImageBlobOutputStream extends CompletableOutputStream {
connection.setAutoCommit(false);
- blob = connection.createBlob();
+ final Configuration config = Configuration.getInstance();
+ final String sql = String.format(
+ "INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)",
+ config.getString(Key.JDBCCACHE_DERIVATIVE_IMAGE_TABLE),
+ DERIVATIVE_IMAGE_TABLE_OPERATIONS_COLUMN,
+ DERIVATIVE_IMAGE_TABLE_IMAGE_COLUMN,
+ DERIVATIVE_IMAGE_TABLE_LAST_ACCESSED_COLUMN);
+ LOGGER.trace(sql);
+
+ final Blob blob = connection.createBlob();
blobOutputStream = blob.setBinaryStream(1);
}
@@ -80,7 +89,7 @@ public void close() throws IOException {
LOGGER.debug("Closing stream for {}", ops);
PreparedStatement statement = null;
try {
- if (isCompletelyWritten()) {
+ if (isComplete()) {
blobOutputStream.close();
final Configuration config = Configuration.getInstance();
final String sql = String.format(
@@ -253,7 +262,7 @@ private void accessDerivativeImage(OperationList opList,
statement.setTimestamp(1, now());
statement.setString(2, opList.toString());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
statement.executeUpdate();
}
}
@@ -288,7 +297,7 @@ private void accessInfo(Identifier identifier, Connection connection)
statement.setTimestamp(1, now());
statement.setString(2, identifier.toString());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
statement.executeUpdate();
}
}
@@ -332,7 +341,7 @@ public Optional getInfo(Identifier identifier) throws IOException {
statement.setString(1, identifier.toString());
statement.setTimestamp(2, earliestValidDate());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
accessInfoAsync(identifier);
@@ -368,7 +377,7 @@ public InputStream newDerivativeImageInputStream(OperationList opList)
statement.setString(1, opList.toString());
statement.setTimestamp(2, earliestValidDate());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
LOGGER.debug("Hit for image: {}", opList);
@@ -431,6 +440,23 @@ public void purge(OperationList ops) throws IOException {
}
}
+ @Override
+ public void purgeInfos() throws IOException {
+ try (Connection connection = getConnection()) {
+ connection.setAutoCommit(false);
+ int numDeleted;
+ final String sql = "DELETE FROM " + getInfoTableName();
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ LOGGER.trace(sql);
+ numDeleted = statement.executeUpdate();
+ }
+ connection.commit();
+ LOGGER.debug("purgeInfos(): purged {} info(s)", numDeleted);
+ } catch (SQLException e) {
+ throw new IOException(e.getMessage(), e);
+ }
+ }
+
@Override
public void purgeInvalid() throws IOException {
try (Connection connection = getConnection()) {
@@ -457,7 +483,7 @@ private int purgeExpiredDerivativeImages(Connection conn)
DERIVATIVE_IMAGE_TABLE_LAST_ACCESSED_COLUMN);
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setTimestamp(1, earliestValidDate());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -472,7 +498,7 @@ private int purgeExpiredInfos(Connection conn)
getInfoTableName(), INFO_TABLE_LAST_ACCESSED_COLUMN);
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setTimestamp(1, earliestValidDate());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -489,7 +515,7 @@ private int purgeDerivativeImage(OperationList ops, Connection conn)
DERIVATIVE_IMAGE_TABLE_OPERATIONS_COLUMN);
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setString(1, ops.toString());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -516,7 +542,7 @@ private void purgeDerivativeImageAsync(OperationList ops) {
private int purgeDerivativeImages(Connection conn) throws SQLException {
final String sql = "DELETE FROM " + getDerivativeImageTableName();
try (PreparedStatement statement = conn.prepareStatement(sql)) {
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -536,7 +562,7 @@ private int purgeDerivativeImages(Identifier identifier, Connection conn)
" LIKE ?";
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setString(1, identifier.toString() + "%");
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -570,7 +596,7 @@ private int purgeInfo(Identifier identifier, Connection conn)
getInfoTableName(), INFO_TABLE_IDENTIFIER_COLUMN);
try (PreparedStatement statement = conn.prepareStatement(sql)) {
statement.setString(1, identifier.toString());
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -592,7 +618,7 @@ private void purgeInfoAsync(Identifier identifier) {
private int purgeInfos(Connection conn) throws SQLException {
final String sql = "DELETE FROM " + getInfoTableName();
try (PreparedStatement statement = conn.prepareStatement(sql)) {
- LOGGER.debug(sql);
+ LOGGER.trace(sql);
return statement.executeUpdate();
}
}
@@ -600,7 +626,7 @@ private int purgeInfos(Connection conn) throws SQLException {
@Override
public void put(Identifier identifier, Info info) throws IOException {
if (!info.isPersistable()) {
- LOGGER.debug("put(): info for {} is incomplete; ignoring",
+ LOGGER.trace("put(): info for {} is incomplete; ignoring",
identifier);
return;
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/RedisCache.java b/src/main/java/edu/illinois/library/cantaloupe/cache/RedisCache.java
index 4d828c9ab..6996fde1b 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/cache/RedisCache.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/RedisCache.java
@@ -31,14 +31,16 @@
*
*
Wraps a {@link ByteArrayOutputStream} for upload to S3.
- *
- *
N.B.: S3 does not allow uploads without a {@code Content-Length}
- * header, which cannot be provided when streaming an unknown amount of
- * data (which this class is going to be doing all the time). From the
- * documentation of {@link PutObjectRequest}:
- *
- *
"When uploading directly from an input stream, content
- * length must be specified before data can be uploaded to Amazon S3. If
- * not provided, the library will have to buffer the contents of the input
- * stream in order to calculate it. Amazon S3 explicitly requires that the
- * content length be sent in the request headers before any of the data is
- * sent."
- *
- *
Since it's not possible to write an {@link OutputStream} of unknown
- * length to the S3 client as the {@link Cache} interface requires, this
- * class buffers written data in a byte array before uploading it to S3
- * upon closure. (The upload is submitted to the
- * {@link ThreadPool#getInstance() application thread pool} in order for
- * {@link #close()} to be able to return immediately.)
- */
- private static class S3OutputStream extends CompletableOutputStream {
-
- private final ByteArrayOutputStream bufferStream =
- new ByteArrayOutputStream();
- private final S3Client client;
- private final String bucketName;
- private final String objectKey;
- private final String contentType;
-
- /**
- * @param client S3 client.
- * @param bucketName S3 bucket name.
- * @param objectKey S3 object key.
- * @param contentType Media type.
- */
- S3OutputStream(final S3Client client,
- final String bucketName,
- final String objectKey,
- final String contentType) {
- this.client = client;
- this.bucketName = bucketName;
- this.objectKey = objectKey;
- this.contentType = contentType;
- }
-
- @Override
- public void close() throws IOException {
- try {
- bufferStream.close();
- byte[] data = bufferStream.toByteArray();
- if (isCompletelyWritten()) {
- // At this point, the client has received all image data,
- // but it is still waiting for the connection to close.
- // Uploading in a separate thread will allow this to happen
- // immediately.
- ThreadPool.getInstance().submit(new S3Upload(
- client, data, bucketName, objectKey,
- contentType, null));
- }
- } finally {
- super.close();
- }
- }
-
- @Override
- public void flush() throws IOException {
- bufferStream.flush();
- }
-
- @Override
- public void write(int b) {
- bufferStream.write(b);
- }
-
- @Override
- public void write(byte[] b) throws IOException {
- bufferStream.write(b);
- }
-
- @Override
- public void write(byte[] b, int off, int len) {
- bufferStream.write(b, off, len);
- }
-
- }
-
- private static class S3Upload implements Runnable {
-
- private static final Logger UPLOAD_LOGGER =
- LoggerFactory.getLogger(S3Upload.class);
-
- private final String bucketName, contentEncoding, contentType, objectKey;
- private final byte[] data;
- private final S3Client client;
-
- /**
- * @param client S3 client.
- * @param data Data to upload.
- * @param bucketName S3 bucket name.
- * @param objectKey S3 object key.
- * @param contentType Media type.
- * @param contentEncoding Content encoding. May be {@code null}.
- */
- S3Upload(S3Client client,
- byte[] data,
- String bucketName,
- String objectKey,
- String contentType,
- String contentEncoding) {
- this.client = client;
- this.bucketName = bucketName;
- this.data = data;
- this.contentType = contentType;
- this.contentEncoding = contentEncoding;
- this.objectKey = objectKey;
- }
-
- @Override
- public void run() {
- if (data.length > 0) {
- PutObjectRequest request = PutObjectRequest.builder()
- .bucket(bucketName)
- .key(objectKey)
- .contentType(contentType)
- .contentEncoding(contentEncoding)
- .build();
- final Stopwatch watch = new Stopwatch();
-
- UPLOAD_LOGGER.debug("Uploading {} bytes to {} in bucket {}",
- data.length, request.key(), request.bucket());
-
- try (ByteArrayInputStream is = new ByteArrayInputStream(data)) {
- client.putObject(request,
- RequestBody.fromInputStream(is, data.length));
- } catch (IOException e) {
- UPLOAD_LOGGER.warn(e.getMessage(), e);
- }
-
- UPLOAD_LOGGER.trace("Wrote {} bytes to {} in bucket {} in {}",
- data.length, request.key(), request.bucket(),
- watch);
- } else {
- UPLOAD_LOGGER.trace("No data to upload; returning");
- }
- }
-
- }
-
private static final Logger LOGGER =
LoggerFactory.getLogger(S3Cache.class);
+ private static final String IMAGE_KEY_PREFIX = "image/";
+ private static final String INFO_EXTENSION = ".json";
+ private static final String INFO_KEY_PREFIX = "info/";
+
/**
* Lazy-initialized by {@link #getClientInstance}.
*/
@@ -283,6 +135,11 @@ public Optional getInfo(Identifier identifier) throws IOException {
// This extra validity check may be needed with minio server
if (is != null && is.response().lastModified().isAfter(earliestValidInstant())) {
final Info info = Info.fromJSON(is);
+ // Populate the serialization timestamp if it is not already,
+ // as suggested by the method contract.
+ if (info.getSerializationTimestamp() == null) {
+ info.setSerializationTimestamp(is.response().lastModified());
+ }
LOGGER.debug("getInfo(): read {} from bucket {} in {}",
objectKey, bucketName, watch);
touchAsync(objectKey);
@@ -341,10 +198,10 @@ public InputStream newDerivativeImageInputStream(OperationList opList)
@Override
public CompletableOutputStream
newDerivativeImageOutputStream(OperationList opList) {
- final String objectKey = getObjectKey(opList);
- final String bucketName = getBucketName();
- final S3Client client = getClientInstance();
- return new S3OutputStream(client, bucketName, objectKey,
+ final String objectKey = getObjectKey(opList);
+ final String bucketName = getBucketName();
+ final S3Client client = getClientInstance();
+ return new S3MultipartAsyncOutputStream(client, bucketName, objectKey,
opList.getOutputFormat().getPreferredMediaType().toString());
}
@@ -353,8 +210,8 @@ public InputStream newDerivativeImageInputStream(OperationList opList)
* given identifier.
*/
String getObjectKey(Identifier identifier) {
- return getObjectKeyPrefix() + "info/" +
- StringUtils.md5(identifier.toString()) + ".json";
+ return getObjectKeyPrefix() + INFO_KEY_PREFIX +
+ StringUtils.md5(identifier.toString()) + INFO_EXTENSION;
}
/**
@@ -370,8 +227,8 @@ String getObjectKey(OperationList opList) {
if (encode != null) {
extension = "." + encode.getFormat().getPreferredExtension();
}
- return String.format("%simage/%s/%s%s",
- getObjectKeyPrefix(), idHash, opsHash, extension);
+ return getObjectKeyPrefix() + IMAGE_KEY_PREFIX + idHash + "/" +
+ opsHash + extension;
}
/**
@@ -415,7 +272,7 @@ public void purge(final Identifier identifier) {
// purge images
final S3Client client = getClientInstance();
final String bucketName = getBucketName();
- final String prefix = getObjectKeyPrefix() + "image/" +
+ final String prefix = getObjectKeyPrefix() + IMAGE_KEY_PREFIX +
StringUtils.md5(identifier.toString());
final AtomicInteger counter = new AtomicInteger();
@@ -456,6 +313,24 @@ private void purgeAsync(final String bucketName, final String key) {
});
}
+ @Override
+ public void purgeInfos() {
+ final S3Client client = getClientInstance();
+ final String bucketName = getBucketName();
+ final String prefix = getObjectKeyPrefix() + INFO_KEY_PREFIX;
+ final AtomicInteger counter = new AtomicInteger();
+
+ S3Utils.walkObjects(client, bucketName, prefix, (object) -> {
+ LOGGER.trace("purgeInfos(): deleting {}", object.key());
+ client.deleteObject(DeleteObjectRequest.builder()
+ .bucket(bucketName)
+ .key(object.key())
+ .build());
+ counter.incrementAndGet();
+ });
+ LOGGER.debug("purgeInfos(): deleted {} items", counter.get());
+ }
+
@Override
public void purgeInvalid() {
final S3Client client = getClientInstance();
@@ -506,13 +381,28 @@ public void put(Identifier identifier, Info info) throws IOException {
@Override
public void put(Identifier identifier, String info) throws IOException {
LOGGER.debug("put(): caching info for {}", identifier);
- final S3Client client = getClientInstance();
- final String objectKey = getObjectKey(identifier);
- final String bucketName = getBucketName();
+ final Stopwatch watch = new Stopwatch();
+
+ PutObjectRequest request = PutObjectRequest.builder()
+ .bucket(getBucketName())
+ .key(getObjectKey(identifier))
+ .contentType(MediaType.APPLICATION_JSON.toString())
+ .contentEncoding("UTF-8")
+ .build();
+ byte[] data = info.getBytes(StandardCharsets.UTF_8);
+ LOGGER.trace("put(): uploading {} bytes to {} in bucket {}",
+ data.length, request.key(), request.bucket());
+
+ try (ByteArrayInputStream is = new ByteArrayInputStream(data)) {
+ getClientInstance().putObject(request,
+ RequestBody.fromInputStream(is, data.length));
+ } catch (IOException e) {
+ LOGGER.warn(e.getMessage(), e);
+ }
- new S3Upload(client, info.getBytes(StandardCharsets.UTF_8),
- bucketName, objectKey, MediaType.APPLICATION_JSON.toString(),
- "UTF-8").run();
+ LOGGER.trace("put(): wrote {} bytes to {} in bucket {} in {}",
+ data.length, request.key(), request.bucket(),
+ watch);
}
/**
diff --git a/src/main/java/edu/illinois/library/cantaloupe/cache/S3MultipartAsyncOutputStream.java b/src/main/java/edu/illinois/library/cantaloupe/cache/S3MultipartAsyncOutputStream.java
new file mode 100644
index 000000000..56990bf74
--- /dev/null
+++ b/src/main/java/edu/illinois/library/cantaloupe/cache/S3MultipartAsyncOutputStream.java
@@ -0,0 +1,322 @@
+package edu.illinois.library.cantaloupe.cache;
+
+import edu.illinois.library.cantaloupe.async.ThreadPool;
+import org.apache.commons.compress.utils.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
+import software.amazon.awssdk.services.s3.model.CompletedPart;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.UploadPartRequest;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+/**
+ *
Uploads written data to S3 in parts without blocking on uploads.
+ *
+ *
The multi-part upload process involves three types of operations:
+ * creating the upload, uploading the parts, and completing the upload. Each of
+ * these are encapsulated in {@link Runnable runnable} inner classes. The
+ * {@link #write} methods add appropriate instances of these to a queue which
+ * is consumed by a worker running in the {@link ThreadPool#getInstance()
+ * application thread pool}.
+ *
+ *
Clients will notice that calls to {@link #write} and {@link #close()}
+ * (that would otherwise block on communication with S3) return immediately.
+ * After {@link #close()} returns, the resulting object will take a little bit
+ * of time to appear in the bucket.
+ *
+ *
Note that because this is a {@link CompletableOutputStream}, if the
+ * instance is not {@link #setComplete(boolean) marked as complete} before
+ * closure, the upload will be aborted.
+ *
+ *
Multi-part uploads can reduce memory usage when uploading objects larger
+ * than the part length, as that is roughly the maximum amount that has to be
+ * buffered in memory (provided that the length of the byte array passed to
+ * either of the {@link #write} methods is not greater than the part
+ * length).
+ *
+ *
N.B.: Incomplete uploads should be aborted automatically, but when using
+ * Amazon S3, it may be helpful to enable the {@literal
+ * AbortIncompleteMultipartUpload} lifecycle rule as a fallback.
+ *
+ * @author Alex Dolski UIUC
+ * @since 6.0
+ */
+public class S3MultipartAsyncOutputStream extends CompletableOutputStream {
+
+ private interface TerminalTask {}
+
+ private static class Worker implements Runnable {
+ private final BlockingQueue workQueue =
+ new LinkedBlockingQueue<>();
+ private boolean isDone, isStopped;
+
+ void add(Runnable task) {
+ workQueue.add(task);
+ }
+
+ @Override
+ public void run() {
+ while (!isDone && !isStopped) {
+ try {
+ Runnable task = workQueue.take();
+ task.run();
+ if (task instanceof TerminalTask) {
+ isDone = true;
+ }
+ } catch (InterruptedException e) {
+ isStopped = true;
+ }
+ }
+ }
+ }
+
+ private class RequestCreator implements Runnable {
+ private final Logger logger =
+ LoggerFactory.getLogger(RequestCreator.class);
+
+ @Override
+ public void run() {
+ logger.trace("Creating request [bucket: {}] [key: {}]",
+ bucket, key);
+ CreateMultipartUploadRequest createMultipartUploadRequest =
+ CreateMultipartUploadRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .contentType(contentType)
+ .contentEncoding("UTF-8")
+ .build();
+ CreateMultipartUploadResponse response =
+ client.createMultipartUpload(createMultipartUploadRequest);
+ uploadID = response.uploadId();
+ }
+ }
+
+ private class PartUploader implements Runnable {
+ private final Logger logger =
+ LoggerFactory.getLogger(PartUploader.class);
+
+ private final ByteArrayOutputStream part;
+
+ PartUploader(ByteArrayOutputStream part) {
+ this.part = part;
+ }
+
+ @Override
+ public void run() {
+ try {
+ final int partNumber = partIndex + 1;
+
+ UploadPartRequest uploadPartRequest = UploadPartRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .uploadId(uploadID)
+ .partNumber(partNumber)
+ .build();
+
+ // There is a small chance that the last part will be empty.
+ if (part.size() == 0) {
+ logger.trace("Skipping empty part {} [upload ID: {}]",
+ partNumber, uploadID);
+ return;
+ }
+
+ byte[] bytes = part.toByteArray();
+
+ logger.trace("Uploading part {} ({} bytes) [upload ID: {}]",
+ uploadPartRequest.partNumber(), bytes.length, uploadID);
+
+ String etag = client.uploadPart(
+ uploadPartRequest,
+ RequestBody.fromBytes(bytes)).eTag();
+ CompletedPart completedPart = CompletedPart.builder()
+ .partNumber(uploadPartRequest.partNumber())
+ .eTag(etag)
+ .build();
+ completedParts.add(completedPart);
+ } finally {
+ IOUtils.closeQuietly(part);
+ }
+ }
+ }
+
+ private class RequestCompleter implements Runnable, TerminalTask {
+ private final Logger logger =
+ LoggerFactory.getLogger(RequestCompleter.class);
+
+ @Override
+ public void run() {
+ try {
+ logger.trace("Completing {}-part request [upload ID: {}]",
+ completedParts.size(), uploadID);
+
+ CompletedMultipartUpload completedMultipartUpload =
+ CompletedMultipartUpload.builder()
+ .parts(completedParts)
+ .build();
+ CompleteMultipartUploadRequest completeMultipartUploadRequest =
+ CompleteMultipartUploadRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .uploadId(uploadID)
+ .multipartUpload(completedMultipartUpload)
+ .build();
+ client.completeMultipartUpload(completeMultipartUploadRequest);
+ setComplete(true); // CompletableOutputStream method
+ } catch (S3Exception e) {
+ logger.warn(e.getMessage());
+ } finally {
+ if (observer != null) {
+ synchronized (instance) {
+ instance.notifyAll();
+ }
+ }
+ }
+ }
+ }
+
+ private class RequestAborter implements Runnable, TerminalTask {
+ private final Logger logger =
+ LoggerFactory.getLogger(RequestAborter.class);
+
+ @Override
+ public void run() {
+ try {
+ logger.trace("Aborting multipart request [upload ID: {}]",
+ uploadID);
+
+ AbortMultipartUploadRequest abortMultipartUploadRequest =
+ AbortMultipartUploadRequest.builder()
+ .bucket(bucket)
+ .key(key)
+ .uploadId(uploadID)
+ .build();
+ client.abortMultipartUpload(abortMultipartUploadRequest);
+ setComplete(false);
+ } catch (S3Exception e) {
+ logger.warn(e.getMessage());
+ } finally {
+ if (observer != null) {
+ synchronized (instance) {
+ instance.notifyAll();
+ }
+ }
+ }
+ }
+ }
+
+ /** 5 MB is the minimum allowed by S3 for all but the last part. */
+ public static final int MINIMUM_PART_LENGTH = 1024 * 1024 * 5;
+
+ private final S3Client client;
+ private final String bucket, key, contentType;
+
+ private ByteArrayOutputStream currentPart;
+ private final List completedParts = new ArrayList<>();
+ private final Worker worker = new Worker();
+ private boolean requestCreated;
+
+ private String uploadID;
+ private int partIndex;
+ private long indexWithinPart;
+
+ /** For an instance to wait for an upload notification during testing. */
+ Object observer;
+
+ /** Helps notify {@link #observer} of a completed upload during testing. */
+ private final S3MultipartAsyncOutputStream instance;
+
+ /**
+ * @param client Client.
+ * @param bucket Target bucket.
+ * @param key Target key.
+ * @param contentType Content type of the created object.
+ */
+ public S3MultipartAsyncOutputStream(S3Client client,
+ String bucket,
+ String key,
+ String contentType) {
+ this.client = client;
+ this.bucket = bucket;
+ this.key = key;
+ this.contentType = contentType;
+ this.instance = this;
+ ThreadPool.getInstance().submit(worker);
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (isComplete()) {
+ worker.add(new PartUploader(getCurrentPart()));
+ // The worker will exit after running this.
+ worker.add(new RequestCompleter());
+ } else {
+ // The worker will exit after running this.
+ worker.add(new RequestAborter());
+ }
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ ByteArrayOutputStream part = getCurrentPart();
+ part.write(b);
+ indexWithinPart++;
+ createRequestIfNecessary();
+ uploadPartIfNecessary();
+ }
+
+ @Override
+ public void write(byte[] b) throws IOException {
+ ByteArrayOutputStream part = getCurrentPart();
+ part.write(b);
+ indexWithinPart += b.length;
+ createRequestIfNecessary();
+ uploadPartIfNecessary();
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ ByteArrayOutputStream part = getCurrentPart();
+ part.write(b, off, len);
+ indexWithinPart += len;
+ createRequestIfNecessary();
+ uploadPartIfNecessary();
+ }
+
+ private ByteArrayOutputStream getCurrentPart() {
+ if (currentPart == null) {
+ currentPart = new ByteArrayOutputStream();
+ }
+ return currentPart;
+ }
+
+ private void createRequestIfNecessary() {
+ if (!requestCreated) {
+ worker.add(new RequestCreator());
+ requestCreated = true;
+ }
+ }
+
+ private void uploadPartIfNecessary() {
+ if (indexWithinPart >= MINIMUM_PART_LENGTH) {
+ worker.add(new PartUploader(currentPart));
+ IOUtils.closeQuietly(currentPart);
+ currentPart = null;
+ indexWithinPart = 0;
+ partIndex++;
+ }
+ }
+
+}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/config/Key.java b/src/main/java/edu/illinois/library/cantaloupe/config/Key.java
index 6ecab5cf9..2ac19ce09 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/config/Key.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/config/Key.java
@@ -91,6 +91,7 @@ public enum Key {
FILESYSTEMSOURCE_PATH_PREFIX("FilesystemSource.BasicLookupStrategy.path_prefix"),
FILESYSTEMSOURCE_PATH_SUFFIX("FilesystemSource.BasicLookupStrategy.path_suffix"),
HEALTH_DEPENDENCY_CHECK("endpoint.health.dependency_check"),
+ HEALTH_ENDPOINT_ENABLED("endpoint.health.enabled"),
HEAPCACHE_PATHNAME("HeapCache.persist.filesystem.pathname"),
HEAPCACHE_PERSIST("HeapCache.persist"),
HEAPCACHE_TARGET_SIZE("HeapCache.target_size"),
@@ -107,8 +108,11 @@ public enum Key {
HTTPSOURCE_CHUNK_SIZE("HttpSource.chunking.chunk_size"),
HTTPSOURCE_CHUNK_CACHE_ENABLED("HttpSource.chunking.cache.enabled"),
HTTPSOURCE_CHUNK_CACHE_MAX_SIZE("HttpSource.chunking.cache.max_size"),
+ HTTPSOURCE_HTTP_PROXY_HOST("HttpSource.proxy.http.host"),
+ HTTPSOURCE_HTTP_PROXY_PORT("HttpSource.proxy.http.port"),
HTTPSOURCE_LOOKUP_STRATEGY("HttpSource.lookup_strategy"),
HTTPSOURCE_REQUEST_TIMEOUT("HttpSource.request_timeout"),
+ HTTPSOURCE_SEND_HEAD_REQUESTS("HttpSource.BasicLookupStrategy.send_head_requests"),
HTTPSOURCE_URL_PREFIX("HttpSource.BasicLookupStrategy.url_prefix"),
HTTPSOURCE_URL_SUFFIX("HttpSource.BasicLookupStrategy.url_suffix"),
HTTPS_ENABLED("https.enabled"),
@@ -170,6 +174,7 @@ public enum Key {
PROCESSOR_FALLBACK_RETRIEVAL_STRATEGY("processor.fallback_retrieval_strategy"),
PROCESSOR_JPG_PROGRESSIVE("processor.jpg.progressive"),
PROCESSOR_JPG_QUALITY("processor.jpg.quality"),
+ PROCESSOR_PURGE_INCOMPATIBLE_FROM_SOURCE_CACHE("processor.purge_incompatible_from_source_cache"),
PROCESSOR_SELECTION_STRATEGY("processor.selection_strategy"),
PROCESSOR_SHARPEN("processor.sharpen"),
PROCESSOR_STREAM_RETRIEVAL_STRATEGY("processor.stream_retrieval_strategy"),
diff --git a/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateMethod.java b/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateMethod.java
index fd9adb6db..845ec7d67 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateMethod.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateMethod.java
@@ -54,6 +54,13 @@ public enum DelegateMethod {
*/
JDBCSOURCE_MEDIA_TYPE("jdbcsource_media_type"),
+ /**
+ * Called by {@link DelegateProxy#getJdbcSourceLastModified()}.
+ *
+ * @since 6.0
+ */
+ JDBCSOURCE_LAST_MODIFIED("jdbcsource_last_modified"),
+
/**
* Called by {@link DelegateProxy#getJdbcSourceLookupSQL()}.
*/
diff --git a/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxy.java b/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxy.java
index 01730cb5d..1fee9ef7a 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxy.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxy.java
@@ -87,6 +87,13 @@ Map getExtraIIIF3InformationResponseKeys()
*/
String getJdbcSourceDatabaseIdentifier() throws ScriptException;
+ /**
+ * @return Return value of {@link DelegateMethod#JDBCSOURCE_LAST_MODIFIED}.
+ * May be {@code null}.
+ * @since 6.0
+ */
+ String getJdbcSourceLastModified() throws ScriptException;
+
/**
* @return Return value of {@link DelegateMethod#JDBCSOURCE_MEDIA_TYPE}.
*/
diff --git a/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxyService.java b/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxyService.java
index 90ed0ee46..6c9c91c14 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxyService.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/delegate/DelegateProxyService.java
@@ -26,6 +26,9 @@ public final class DelegateProxyService {
private static final Logger LOGGER =
LoggerFactory.getLogger(DelegateProxyService.class);
+ static final String DELEGATE_SCRIPT_VM_ARGUMENT =
+ "cantaloupe.delegate_script";
+
private static DelegateProxyService instance;
private static boolean isScriptCodeLoaded;
@@ -95,23 +98,29 @@ public static synchronized DelegateProxyService getInstance() {
}
/**
- * @return Absolute path representing the delegate script, regardless of
- * whether the delegate script system is {@link #isScriptEnabled()
- * enabled}; or {@code null} if {@link
- * Key#DELEGATE_SCRIPT_PATHNAME} is not set.
+ *
Returns the absolute path to the delegate script, regardless of
+ * whether the delegate script system is {@link #isScriptEnabled()
+ * enabled}. The path is obtained from the {@link
+ * #DELEGATE_SCRIPT_VM_ARGUMENT delegate script VM argument}, if set, or
+ * the {@link Key#DELEGATE_SCRIPT_PATHNAME configuration} otherwise. If
+ * neither are set, set, {@code null} is returned.
+ *
+ *
The contents of the script are not validated.
+ *
* @throws NoSuchFileException If the script specified in {@link
* Key#DELEGATE_SCRIPT_PATHNAME} does not exist.
*/
static Path getScriptFile() throws NoSuchFileException {
- final Configuration config = Configuration.getInstance();
- // The script name may be an absolute pathname or a filename.
- final String configValue =
- config.getString(Key.DELEGATE_SCRIPT_PATHNAME, "");
- if (!configValue.isEmpty()) {
- Path script = findScript(configValue);
+ String value = System.getProperty("cantaloupe.delegate_script");
+ if (value == null || value.isBlank()) {
+ final Configuration config = Configuration.getInstance();
+ // The script name may be an absolute pathname or a filename.
+ value = config.getString(Key.DELEGATE_SCRIPT_PATHNAME, "");
+ }
+ if (!value.isBlank()) {
+ Path script = findScript(value);
if (!Files.exists(script)) {
- throw new NoSuchFileException(
- "File not found: " + script.toString());
+ throw new NoSuchFileException("File not found: " + script);
}
return script;
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/delegate/JRubyDelegateProxy.java b/src/main/java/edu/illinois/library/cantaloupe/delegate/JRubyDelegateProxy.java
index d3a9fa949..cb3222ca6 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/delegate/JRubyDelegateProxy.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/delegate/JRubyDelegateProxy.java
@@ -252,6 +252,16 @@ public String getJdbcSourceDatabaseIdentifier() throws ScriptException {
return (String) result;
}
+ /**
+ * @return Return value of {@link DelegateMethod#JDBCSOURCE_LAST_MODIFIED}.
+ * @since 6.0
+ */
+ @Override
+ public String getJdbcSourceLastModified() throws ScriptException {
+ Object result = invoke(DelegateMethod.JDBCSOURCE_LAST_MODIFIED);
+ return (String) result;
+ }
+
/**
* @return Return value of {@link DelegateMethod#JDBCSOURCE_MEDIA_TYPE}.
*/
diff --git a/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaContext.java b/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaContext.java
index ad0dd04b3..f460cc415 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaContext.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaContext.java
@@ -67,7 +67,14 @@ public interface JavaContext {
* "Field2Name": value
* ],
* "xmp_string": "...",
- * "xmp_model": https://jena.apache.org/documentation/javadoc/jena/org/apache/jena/rdf/model/Model.html
+ * "xmp_model": See https://jena.apache.org/documentation/javadoc/jena/org/apache/jena/rdf/model/Model.html,
+ * "xmp_elements": {
+ * "Field1Name": "value",
+ * "Field2Name": [
+ * "value1",
+ * "value2"
+ * ]
+ * },
* "native": {
* # structure varies
* }
@@ -83,6 +90,10 @@ public interface JavaContext {
*
The {@code xmp_model} key contains a {@link
* org.apache.jena.rdf.model.Model} object pre-loaded with the
* contents of {@code xmp_string}.
+ *
The {@code xmp_elements} key contains a view of the embedded XMP
+ * data as key-value pairs. This is convenient to use, but may not work
+ * correctly with all XMP fields—in particular, those that cannot
+ * be expressed as key-value pairs.
*
The {@code native} key refers to format-specific metadata.
Boolean (optional). Defaults to {@code true}. See the
+ * documentation of the {@code
+ * HttpSource.BasicLookupStrategy.send_head_requests}
+ * configuration key.
Returns either the last-modified timestamp of an image in ISO 8601
+ * format, or an SQL statement that can be used to retrieve it from a
+ * {@code TIMESTAMP}-type column in the database. In the latter case, the
+ * {@code SELECT} and {@code FROM} clauses should be in uppercase in order
+ * to be autodetected.
+ *
+ *
Implementing this method is optional, but may be necessary for
+ * certain features (like {@code Last-Modified} response headers) to
+ * work.
+ *
+ * @since 6.0
+ */
+ String getJDBCSourceLastModified();
+
/**
* Returns either the media (MIME) type of an image, or an SQL statement
- * that can be used to retrieve it, if it is stored in the database. In the
- * latter case, the {@code SELECT} and {@code FROM} clauses should be in
- * uppercase in order to be autodetected. If {@code null} is returned, the
- * media type will be inferred some other way, such as by identifier
- * extension or magic bytes.
+ * that can be used to retrieve it from a {@code CHAR}-type column in the
+ * database. In the latter case, the {@code SELECT} and {@code FROM}
+ * clauses should be in uppercase in order to be autodetected. If {@code
+ * null} is returned, the media type will be inferred some other way, such
+ * as by identifier extension or magic bytes.
*/
String getJDBCSourceMediaType();
diff --git a/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaDelegateProxy.java b/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaDelegateProxy.java
index 79140f038..f13c939ca 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaDelegateProxy.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/delegate/JavaDelegateProxy.java
@@ -63,6 +63,14 @@ public String getJdbcSourceDatabaseIdentifier() {
return delegate.getJDBCSourceDatabaseIdentifier();
}
+ /**
+ * @since 6.0
+ */
+ @Override
+ public String getJdbcSourceLastModified() {
+ return delegate.getJDBCSourceLastModified();
+ }
+
@Override
public String getJdbcSourceLookupSQL() {
return delegate.getJDBCSourceLookupSQL();
diff --git a/src/main/java/edu/illinois/library/cantaloupe/http/Server.java b/src/main/java/edu/illinois/library/cantaloupe/http/Server.java
index 21c9b37e3..ba049467e 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/http/Server.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/http/Server.java
@@ -2,6 +2,7 @@
import edu.illinois.library.cantaloupe.util.SocketUtils;
import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
+import org.eclipse.jetty.http.UriCompliance;
import org.eclipse.jetty.http2.HTTP2Cipher;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
@@ -73,6 +74,7 @@ private void initializeServer() {
ServerConnector connector;
HttpConfiguration config = new HttpConfiguration();
+ config.setUriCompliance(UriCompliance.LEGACY);
HttpConnectionFactory http1 = new HttpConnectionFactory(config);
HTTP2CServerConnectionFactory http2c =
@@ -95,10 +97,11 @@ private void initializeServer() {
// Initialize HTTPS.
if (isHTTPS1Enabled || isHTTPS2Enabled) {
config = new HttpConfiguration();
+ config.setUriCompliance(UriCompliance.LEGACY);
config.setSecureScheme("https");
config.addCustomizer(new SecureRequestCustomizer());
- final SslContextFactory contextFactory =
+ final SslContextFactory.Server contextFactory =
new SslContextFactory.Server();
contextFactory.setKeyStorePath(keyStorePath.toString());
contextFactory.setKeyStorePassword(keyStorePassword);
diff --git a/src/main/java/edu/illinois/library/cantaloupe/image/Info.java b/src/main/java/edu/illinois/library/cantaloupe/image/Info.java
index 12682aee2..522f3c10e 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/image/Info.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/image/Info.java
@@ -17,6 +17,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Path;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -205,11 +206,19 @@ public enum Serialization {
*
*
Introduced in application version 5.0.
*/
- VERSION_4(4);
+ VERSION_4(4),
+
+ /**
+ *
Added a {@code serializationTimestamp} key containing an ISO 8601
+ * timestamp in UTC.
+ *
+ *
Introduced in application version 6.0.
+ */
+ VERSION_5(5);
private final int version;
- static final Serialization CURRENT = VERSION_4;
+ static final Serialization CURRENT = VERSION_5;
Serialization(int version) {
this.version = version;
@@ -220,11 +229,12 @@ int getVersion() {
}
}
- private String appVersion = Application.getVersion();
+ private String appVersion = Application.getVersion();
private Identifier identifier;
private MediaType mediaType;
- private Metadata metadata = new Metadata();
- private Serialization serialization = Serialization.CURRENT;
+ private Metadata metadata = new Metadata();
+ private Serialization serialization = Serialization.CURRENT;
+ private Instant serializationTimestamp;
/**
* Ordered list of subimages. The main image is at index {@code 0}.
@@ -268,19 +278,24 @@ public Info() {
images.add(new Image());
}
+ /**
+ * N.B.: the {@link #getSerializationTimestamp() serialization timestamp}
+ * is not considered.
+ */
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
} else if (obj instanceof Info) {
Info other = (Info) obj;
- return Objects.equals(other.getApplicationVersion(), getApplicationVersion()) &&
- Objects.equals(other.getSerialization(), getSerialization()) &&
- Objects.equals(other.getIdentifier(), getIdentifier()) &&
- Objects.equals(other.getMetadata(), getMetadata()) &&
- Objects.equals(other.getSourceFormat(), getSourceFormat()) &&
- other.getNumResolutions() == getNumResolutions() &&
- other.getImages().equals(getImages());
+ boolean a = Objects.equals(other.getApplicationVersion(), getApplicationVersion());
+ boolean b = Objects.equals(other.getSerialization(), getSerialization());
+ boolean c = Objects.equals(other.getIdentifier(), getIdentifier());
+ boolean d = Objects.equals(other.getMetadata(), getMetadata());
+ boolean e = Objects.equals(other.getSourceFormat(), getSourceFormat());
+ boolean f = other.getNumResolutions() == getNumResolutions();
+ boolean g = other.getImages().equals(getImages());
+ return a && b && c && d && e && f && g;
}
return super.equals(obj);
}
@@ -367,6 +382,20 @@ public Serialization getSerialization() {
return serialization;
}
+ /**
+ * N.B.: Although it would be possible for most {@link
+ * edu.illinois.library.cantaloupe.cache.DerivativeCache}s to obtain a
+ * serialization timestamp by other means, such as e.g. filesystem
+ * attributes, storing it within the serialized instance makes a separate
+ * I/O call unnecessary.
+ *
+ * @return Timestamp that the instance was serialized.
+ * @since 6.0
+ */
+ public Instant getSerializationTimestamp() {
+ return serializationTimestamp;
+ }
+
/**
* @return Size of the main image.
*/
@@ -484,6 +513,14 @@ public void setPersistable(boolean isComplete) {
this.isPersistable = isComplete;
}
+ /**
+ * @param timestamp Time at which the instance is serialized.
+ * @since 6.0
+ */
+ public void setSerializationTimestamp(Instant timestamp) {
+ this.serializationTimestamp = timestamp;
+ }
+
/**
* @param version One of the {@link Serialization} versions. This value is
* not serialized (the current serialization version is
diff --git a/src/main/java/edu/illinois/library/cantaloupe/image/InfoDeserializer.java b/src/main/java/edu/illinois/library/cantaloupe/image/InfoDeserializer.java
index 0800eee0a..e88570eb7 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/image/InfoDeserializer.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/image/InfoDeserializer.java
@@ -8,6 +8,7 @@
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.time.Instant;
import static edu.illinois.library.cantaloupe.image.InfoSerializer.APPLICATION_VERSION_KEY;
import static edu.illinois.library.cantaloupe.image.InfoSerializer.IDENTIFIER_KEY;
@@ -15,6 +16,7 @@
import static edu.illinois.library.cantaloupe.image.InfoSerializer.MEDIA_TYPE_KEY;
import static edu.illinois.library.cantaloupe.image.InfoSerializer.METADATA_KEY;
import static edu.illinois.library.cantaloupe.image.InfoSerializer.NUM_RESOLUTIONS_KEY;
+import static edu.illinois.library.cantaloupe.image.InfoSerializer.SERIALIZATION_TIMESTAMP_KEY;
import static edu.illinois.library.cantaloupe.image.InfoSerializer.SERIALIZATION_VERSION_KEY;
/**
@@ -28,10 +30,16 @@ final class InfoDeserializer extends JsonDeserializer {
public Info deserialize(JsonParser parser,
DeserializationContext deserializationContext) throws IOException {
// N.B.: keys may or may not exist in different serializations,
- // documented inline. Even for keys that are supposed to always exist,
- // we have to check for them anyway because they may not exist in tests.
- final Info info = new Info();
+ // documented inline. Even keys that are supposed to always exist may
+ // not exist in tests, so we have to check for them anyway.
+ final Info info = new Info();
final JsonNode node = parser.getCodec().readTree(parser);
+ { // serializationTimestamp (does not exist in < 6.0 serializations)
+ JsonNode timestampNode = node.get(SERIALIZATION_TIMESTAMP_KEY);
+ if (timestampNode != null) {
+ info.setSerializationTimestamp(Instant.parse(timestampNode.textValue()));
+ }
+ }
{ // applicationVersion (does not exist in < 5.0 serializations)
JsonNode appVersionNode = node.get(APPLICATION_VERSION_KEY);
if (appVersionNode != null) {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/image/InfoSerializer.java b/src/main/java/edu/illinois/library/cantaloupe/image/InfoSerializer.java
index ba70a4499..f337ee27a 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/image/InfoSerializer.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/image/InfoSerializer.java
@@ -7,6 +7,7 @@
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.time.Instant;
/**
* Serializes an {@link Info}.
@@ -15,13 +16,14 @@
*/
final class InfoSerializer extends JsonSerializer {
- static final String APPLICATION_VERSION_KEY = "applicationVersion";
- static final String IDENTIFIER_KEY = "identifier";
- static final String IMAGES_KEY = "images";
- static final String MEDIA_TYPE_KEY = "mediaType";
- static final String METADATA_KEY = "metadata";
- static final String NUM_RESOLUTIONS_KEY = "numResolutions";
- static final String SERIALIZATION_VERSION_KEY = "serializationVersion";
+ static final String APPLICATION_VERSION_KEY = "applicationVersion";
+ static final String IDENTIFIER_KEY = "identifier";
+ static final String IMAGES_KEY = "images";
+ static final String MEDIA_TYPE_KEY = "mediaType";
+ static final String METADATA_KEY = "metadata";
+ static final String NUM_RESOLUTIONS_KEY = "numResolutions";
+ static final String SERIALIZATION_TIMESTAMP_KEY = "serializationTimestamp";
+ static final String SERIALIZATION_VERSION_KEY = "serializationVersion";
@Override
public void serialize(Info info,
@@ -29,19 +31,27 @@ public void serialize(Info info,
SerializerProvider serializerProvider) throws IOException {
generator.writeStartObject();
// application version
- generator.writeStringField(APPLICATION_VERSION_KEY, Application.getVersion());
+ generator.writeStringField(APPLICATION_VERSION_KEY,
+ Application.getVersion());
// serialization version
- generator.writeNumberField(SERIALIZATION_VERSION_KEY, Info.Serialization.CURRENT.getVersion());
+ generator.writeNumberField(SERIALIZATION_VERSION_KEY,
+ Info.Serialization.CURRENT.getVersion());
+ // serialization timestamp
+ generator.writeStringField(SERIALIZATION_TIMESTAMP_KEY,
+ Instant.now().toString());
// identifier
if (info.getIdentifier() != null) {
- generator.writeStringField(IDENTIFIER_KEY, info.getIdentifier().toString());
+ generator.writeStringField(IDENTIFIER_KEY,
+ info.getIdentifier().toString());
}
// mediaType
if (info.getMediaType() != null) {
- generator.writeStringField(MEDIA_TYPE_KEY, info.getMediaType().toString());
+ generator.writeStringField(MEDIA_TYPE_KEY,
+ info.getMediaType().toString());
}
// numResolutions
- generator.writeNumberField(NUM_RESOLUTIONS_KEY, info.getNumResolutions());
+ generator.writeNumberField(NUM_RESOLUTIONS_KEY,
+ info.getNumResolutions());
// images
generator.writeArrayFieldStart(IMAGES_KEY);
info.getImages().forEach(image -> {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/image/Metadata.java b/src/main/java/edu/illinois/library/cantaloupe/image/Metadata.java
index 2d04a64e6..8a2d26819 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/image/Metadata.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/image/Metadata.java
@@ -4,11 +4,11 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
-import edu.illinois.library.cantaloupe.Application;
import edu.illinois.library.cantaloupe.image.exif.Directory;
import edu.illinois.library.cantaloupe.image.exif.Tag;
import edu.illinois.library.cantaloupe.image.iptc.DataSet;
-import edu.illinois.library.cantaloupe.util.StringUtils;
+import edu.illinois.library.cantaloupe.image.xmp.MapReader;
+import edu.illinois.library.cantaloupe.image.xmp.Utils;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.rdf.model.NodeIterator;
@@ -17,6 +17,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -45,9 +46,6 @@ public class Metadata {
private static final String XMP_ORIENTATION_PREDICATE =
"http://ns.adobe.com/tiff/1.0/Orientation";
- private static final String XMP_TOOLKIT = Application.getName() + " " +
- Application.getVersion();
-
protected Directory exif;
protected List iptcDataSets;
protected String xmp;
@@ -63,27 +61,6 @@ public class Metadata {
*/
private transient Orientation orientation;
- /**
- * Returns an XMP string encapsulated in an {@literal x:xmpmeta} element,
- * which is itself encapsulated in an {@literal xpacket} PI.
- *
- * @param xmp XMP string with an {@literal rdf:RDF} root element.
- * @return Encapsulated XMP data packet.
- */
- public static String encapsulateXMP(String xmp) {
- final StringBuilder b = new StringBuilder();
- b.append("");
- b.append("");
- b.append(xmp);
- b.append("");
- // Append the magic trailer
- b.append(" ".repeat(2048));
- b.append("");
- return b.toString();
- }
-
@Override
public boolean equals(Object obj) {
if (obj == this) {
@@ -173,16 +150,32 @@ private void readOrientationFromXMP() {
}
/**
- * Returns an RDF/XML string in UTF-8 encoding. The root element is
- * {@literal rdf:RDF}, and there is no packet wrapper.
- *
- * @return XMP data packet.
+ * @return RDF/XML string in UTF-8 encoding. The root element is {@literal
+ * rdf:RDF}, and there is no packet wrapper.
*/
@JsonProperty
public Optional getXMP() {
return Optional.ofNullable(xmp);
}
+ /**
+ * @return Map of elements found in the XMP data. If none are found, the
+ * map is empty.
+ */
+ @JsonIgnore
+ public Map getXMPElements() {
+ loadXMP();
+ if (xmpModel != null) {
+ try {
+ MapReader reader = new MapReader(xmpModel);
+ return reader.readElements();
+ } catch (IOException e) {
+ LOGGER.warn("getXMPElements(): {}", e.getMessage());
+ }
+ }
+ return Collections.emptyMap();
+ }
+
/**
* @return XMP model backed by the contents of {@link #getXMP()}.
*/
@@ -295,7 +288,7 @@ public void setXMP(byte[] xmp) {
*/
public void setXMP(String xmp) {
if (xmp != null) {
- this.xmp = StringUtils.trimXMP(xmp);
+ this.xmp = Utils.trimXMP(xmp);
} else {
this.xmp = null;
this.xmpModel = null;
@@ -310,7 +303,9 @@ public void setXMP(String xmp) {
* {
* "exif": See {@link Directory#toMap()},
* "iptc": See {@link DataSet#toMap()},
- * "xmp": "...",
+ * "xmp_string": "...",
+ * "xmp_model": [Jena model],
+ * "xmp_elements": {@link Map}
* "native": String
* }}
*
@@ -334,6 +329,7 @@ public Map toMap() {
// XMP
getXMP().ifPresent(xmp -> map.put("xmp_string", xmp));
getXMPModel().ifPresent(model -> map.put("xmp_model", model));
+ map.put("xmp_elements", getXMPElements());
// Native metadata
getNativeMetadata().ifPresent(nm -> map.put("native", nm));
return Collections.unmodifiableMap(map);
diff --git a/src/main/java/edu/illinois/library/cantaloupe/image/xmp/MapReader.java b/src/main/java/edu/illinois/library/cantaloupe/image/xmp/MapReader.java
new file mode 100644
index 000000000..51b608fe6
--- /dev/null
+++ b/src/main/java/edu/illinois/library/cantaloupe/image/xmp/MapReader.java
@@ -0,0 +1,148 @@
+package edu.illinois.library.cantaloupe.image.xmp;
+
+import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.rdf.model.RDFNode;
+import org.apache.jena.rdf.model.Statement;
+import org.apache.jena.rdf.model.StmtIterator;
+import org.apache.jena.riot.RIOT;
+import org.apache.jena.riot.RiotException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Extracts label-value pairs from an XMP model, making them available in a
+ * {@link Map}.
+ *
+ * @author Alex Dolski UIUC
+ * @since 6.0
+ */
+public final class MapReader {
+
+ private static final Logger LOGGER =
+ LoggerFactory.getLogger(MapReader.class);
+
+ private static final Map PREFIXES = Map.ofEntries(
+ Map.entry("http://ns.adobe.com/camera-raw-settings/1.0/", "crs"),
+ Map.entry("http://purl.org/dc/elements/1.1/", "dc"),
+ Map.entry("http://purl.org/dc/terms/", "dcterms"),
+ Map.entry("http://ns.adobe.com/exif/1.0/", "exif"),
+ Map.entry("http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/", "Iptc4xmpCore"),
+ Map.entry("http://ns.adobe.com/iX/1.0/", "iX"),
+ Map.entry("http://ns.adobe.com/pdf/1.3/", "pdf"),
+ Map.entry("http://ns.adobe.com/photoshop/1.0/", "photoshop"),
+ Map.entry("http://ns.adobe.com/tiff/1.0/", "tiff"),
+ Map.entry("http://ns.adobe.com/xap/1.0/", "xmp"),
+ Map.entry("http://ns.adobe.com/xap/1.0/bj/", "xmpBJ"),
+ Map.entry("http://ns.adobe.com/xmp/1.0/DynamicMedia/", "xmpDM"),
+ Map.entry("http://ns.adobe.com/xmp/identifier/qual/1.0/", "xmpidq"),
+ Map.entry("http://ns.adobe.com/xap/1.0/mm/", "xmpMM"),
+ Map.entry("http://ns.adobe.com/xap/1.0/rights/", "xmpRights"),
+ Map.entry("http://ns.adobe.com/xap/1.0/t/pg/", "xmpTPg"));
+
+ private final Model model;
+ private final Map elements = new TreeMap<>();
+ private boolean hasReadElements;
+
+ /**
+ * @param xmp XMP string. {@code } must be the root element.
+ * @see Utils#trimXMP
+ */
+ public MapReader(String xmp) throws IOException {
+ RIOT.init();
+ this.model = ModelFactory.createDefaultModel();
+ try (StringReader reader = new StringReader(xmp)) {
+ model.read(reader, null, "RDF/XML");
+ } catch (RiotException | NullPointerException e) {
+ // The XMP string may be invalid RDF/XML, or there may be a bug
+ // in Jena (that would be the NPE). Not much we can do.
+ throw new IOException(e);
+ }
+ }
+
+ /**
+ * @param model XMP model, already initialized.
+ */
+ public MapReader(Model model) {
+ this.model = model;
+ }
+
+ public Map readElements() throws IOException {
+ if (!hasReadElements) {
+ StmtIterator it = model.listStatements();
+ while (it.hasNext()) {
+ Statement stmt = it.next();
+ //System.out.println(stmt.getSubject() + " " + stmt.getSubject().isAnon());
+ //System.out.println(" " + stmt.getPredicate());
+ //System.out.println(" " + stmt.getObject() + " " + stmt.getObject().isLiteral());
+ //System.out.println("---------------------------");
+ if (!stmt.getSubject().isAnon()) {
+ recurse(stmt);
+ }
+ }
+ LOGGER.trace("readElements(): read {} elements", elements.size());
+ hasReadElements = true;
+ }
+ return Collections.unmodifiableMap(elements);
+ }
+
+ private void recurse(Statement stmt) {
+ recurse(stmt, null);
+ }
+
+ private void recurse(Statement stmt, String predicateOverride) {
+ String predicate = stmt.getPredicate().toString();
+ if (stmt.getObject().isLiteral()) {
+ addElement(label(predicateOverride != null ? predicateOverride : predicate),
+ stmt.getObject().asLiteral().getValue());
+ } else {
+ StmtIterator it = model.listStatements(
+ stmt.getObject().asResource(), null, (RDFNode) null);
+ while (it.hasNext()) {
+ Statement substmt = it.next();
+ predicateOverride = null;
+ if (substmt.getPredicate().toString().matches("(.*)#_\\d+\\b")) {
+ predicateOverride = predicate;
+ }
+ recurse(substmt, predicateOverride);
+ }
+ }
+ }
+
+ private void addElement(String label, Object value) {
+ if (elements.containsKey(label)) {
+ if (elements.get(label) instanceof List) {
+ @SuppressWarnings("unchecked")
+ List
*
- *
Unlike {@link javax.servlet.http.HttpServlet}s, instances are only used
+ *
Unlike {@link jakarta.servlet.http.HttpServlet}s, instances are only used
* once and not shared across threads.
*/
public abstract class AbstractResource {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/FileServlet.java b/src/main/java/edu/illinois/library/cantaloupe/resource/FileServlet.java
index cbad65f02..94551c415 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/FileServlet.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/FileServlet.java
@@ -1,8 +1,8 @@
package edu.illinois.library.cantaloupe.resource;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/HandlerServlet.java b/src/main/java/edu/illinois/library/cantaloupe/resource/HandlerServlet.java
index 2223870ec..b84025a3d 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/HandlerServlet.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/HandlerServlet.java
@@ -8,9 +8,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRepresentation.java b/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRepresentation.java
index d05cd3fa8..46e6d627f 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRepresentation.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRepresentation.java
@@ -129,7 +129,7 @@ public void write(OutputStream responseOS) throws IOException {
"cache simultaneously");
copyOrProcess(teeOS);
cacheOS.flush();
- cacheOS.setCompletelyWritten(true);
+ cacheOS.setComplete(true);
} else {
copyOrProcess(responseOS);
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRequestHandler.java b/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRequestHandler.java
index b5e6f38b2..31f1cfb6f 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRequestHandler.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/ImageRequestHandler.java
@@ -1,5 +1,6 @@
package edu.illinois.library.cantaloupe.resource;
+import edu.illinois.library.cantaloupe.async.TaskQueue;
import edu.illinois.library.cantaloupe.cache.CacheFacade;
import edu.illinois.library.cantaloupe.config.Configuration;
import edu.illinois.library.cantaloupe.config.Key;
@@ -14,6 +15,7 @@
import edu.illinois.library.cantaloupe.processor.ProcessorFactory;
import edu.illinois.library.cantaloupe.processor.SourceFormatException;
import edu.illinois.library.cantaloupe.delegate.DelegateProxy;
+import edu.illinois.library.cantaloupe.source.StatResult;
import edu.illinois.library.cantaloupe.status.HealthChecker;
import edu.illinois.library.cantaloupe.source.Source;
import edu.illinois.library.cantaloupe.source.SourceFactory;
@@ -23,6 +25,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
@@ -181,6 +184,13 @@ public interface Callback {
*/
boolean authorize() throws Exception;
+ /**
+ * Called immediately after the source image has first been accessed.
+ *
+ * @param result Information about the source image.
+ */
+ void sourceAccessed(StatResult result);
+
/**
* Called when image information is available; always before {@link
* #willProcessImage(Processor, Info)} and {@link
@@ -230,6 +240,9 @@ public boolean authorize() {
return true;
}
@Override
+ public void sourceAccessed(StatResult result) {
+ }
+ @Override
public void willStreamImageFromDerivativeCache() {
}
@Override
@@ -335,7 +348,8 @@ public void handle(OutputStream outputStream) throws Exception {
final Optional sourceImage = cacheFacade.getSourceCacheFile(identifier);
if (sourceImage.isEmpty() || isResolvingFirst()) {
try {
- source.checkAccess();
+ StatResult result = source.stat();
+ callback.sourceAccessed(result);
} catch (NoSuchFileException e) { // this needs to be rethrown!
if (config.getBoolean(Key.CACHE_SERVER_PURGE_MISSING, false)) {
// If the image was not found, purge it from the cache.
@@ -416,6 +430,23 @@ public void handle(OutputStream outputStream) throws Exception {
format, identifier);
}
}
+ if (config.getBoolean(Key.PROCESSOR_PURGE_INCOMPATIBLE_FROM_SOURCE_CACHE, false)) {
+ TaskQueue.getInstance().submit(() -> {
+ try {
+ cacheFacade.getSourceCacheFile(identifier).ifPresent(file -> {
+ try {
+ getLogger().debug("Deleting {}", file);
+ Files.delete(file);
+ } catch (IOException e) {
+ getLogger().warn("Failed to delete file from source cache: {}",
+ e.getMessage());
+ }
+ });
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ }
throw new SourceFormatException();
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/InformationRequestHandler.java b/src/main/java/edu/illinois/library/cantaloupe/resource/InformationRequestHandler.java
index 74dd9cd02..61e140b81 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/InformationRequestHandler.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/InformationRequestHandler.java
@@ -14,6 +14,7 @@
import edu.illinois.library.cantaloupe.delegate.DelegateProxy;
import edu.illinois.library.cantaloupe.source.Source;
import edu.illinois.library.cantaloupe.source.SourceFactory;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,7 +51,7 @@ public class InformationRequestHandler extends AbstractRequestHandler
*/
public static final class Builder {
- private InformationRequestHandler handler;
+ private final InformationRequestHandler handler;
private Builder(InformationRequestHandler handler) {
this.handler = handler;
@@ -151,7 +152,14 @@ public interface Callback {
boolean authorize() throws Exception;
/**
- * Called when the list of available output formats have been obtained
+ * Called immediately after a source image has been accessed.
+ *
+ * @param result Information about the source image.
+ */
+ void sourceAccessed(StatResult result);
+
+ /**
+ * Called when a list of available output formats has been obtained
* from a {@link Processor}.
*/
void knowAvailableOutputFormats(Set availableOutputFormats);
@@ -168,6 +176,9 @@ public boolean authorize() {
return true;
}
@Override
+ public void sourceAccessed(StatResult sourceAvailable) {
+ }
+ @Override
public void knowAvailableOutputFormats(Set availableOutputFormats) {
}
};
@@ -249,7 +260,8 @@ public Info handle() throws Exception {
final Optional optSrcImage = cacheFacade.getSourceCacheFile(identifier);
if (optSrcImage.isEmpty() || isResolvingFirst()) {
try {
- source.checkAccess();
+ StatResult result = source.stat();
+ callback.sourceAccessed(result);
} catch (NoSuchFileException e) { // this needs to be rethrown!
if (config.getBoolean(Key.CACHE_SERVER_PURGE_MISSING, false)) {
// If the image was not found, purge it from the cache.
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/Request.java b/src/main/java/edu/illinois/library/cantaloupe/resource/Request.java
index 55588eab4..9e36c9a38 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/Request.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/Request.java
@@ -6,7 +6,7 @@
import edu.illinois.library.cantaloupe.http.Query;
import edu.illinois.library.cantaloupe.http.Reference;
-import javax.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.util.Enumeration;
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/Route.java b/src/main/java/edu/illinois/library/cantaloupe/resource/Route.java
index 1b4fe0d45..4bfb4111e 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/Route.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/Route.java
@@ -89,7 +89,7 @@ public final class Route {
MAPPINGS.put(Pattern.compile("^" + CONFIGURATION_PATH + "$"),
edu.illinois.library.cantaloupe.resource.api.ConfigurationResource.class);
MAPPINGS.put(Pattern.compile("^" + HEALTH_PATH + "$"),
- edu.illinois.library.cantaloupe.resource.api.HealthResource.class);
+ edu.illinois.library.cantaloupe.resource.health.HealthResource.class);
MAPPINGS.put(Pattern.compile("^" + STATUS_PATH + "$"),
edu.illinois.library.cantaloupe.resource.api.StatusResource.class);
MAPPINGS.put(Pattern.compile("^" + TASKS_PATH + "$"),
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/AbstractAPIResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/AbstractAPIResource.java
index 6c5796ace..e41473d71 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/AbstractAPIResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/AbstractAPIResource.java
@@ -22,17 +22,13 @@ public void doInit() throws Exception {
throw new EndpointDisabledException();
}
- if (requiresAuth()) {
- authenticateUsingBasic(BASIC_REALM, user -> {
- final String configUser = config.getString(Key.API_USERNAME, "");
- if (!configUser.isEmpty() && configUser.equals(user)) {
- return config.getString(Key.API_SECRET);
- }
- return null;
- });
- }
+ authenticateUsingBasic(BASIC_REALM, user -> {
+ final String configUser = config.getString(Key.API_USERNAME, "");
+ if (!configUser.isEmpty() && configUser.equals(user)) {
+ return config.getString(Key.API_SECRET);
+ }
+ return null;
+ });
}
- abstract boolean requiresAuth();
-
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/Command.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/Command.java
index a7d9b2e8d..5722814e0 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/Command.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/Command.java
@@ -24,6 +24,9 @@
@JsonSubTypes.Type(
name = "PurgeInfoCache",
value = PurgeInfoCacheCommand.class),
+ @JsonSubTypes.Type(
+ name = "PurgeInfosFromCache",
+ value = PurgeInfosFromCacheCommand.class),
@JsonSubTypes.Type(
name = "PurgeInvalidFromCache",
value = PurgeInvalidFromCacheCommand.class),
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/ConfigurationResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/ConfigurationResource.java
index b1f223c88..bed8838fe 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/ConfigurationResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/ConfigurationResource.java
@@ -77,9 +77,4 @@ public void doPUT() throws IOException {
getResponse().setStatus(Status.NO_CONTENT.getCode());
}
- @Override
- boolean requiresAuth() {
- return true;
- }
-
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/PurgeInfosFromCacheCommand.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/PurgeInfosFromCacheCommand.java
new file mode 100644
index 000000000..d0cbbb9c4
--- /dev/null
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/PurgeInfosFromCacheCommand.java
@@ -0,0 +1,24 @@
+package edu.illinois.library.cantaloupe.resource.api;
+
+import edu.illinois.library.cantaloupe.cache.CacheFacade;
+
+import java.util.concurrent.Callable;
+
+/**
+ * @since 6.0
+ */
+final class PurgeInfosFromCacheCommand extends Command
+ implements Callable {
+
+ @Override
+ public T call() throws Exception {
+ new CacheFacade().purgeInfos();
+ return null;
+ }
+
+ @Override
+ String getVerb() {
+ return "PurgeInfosFromCache";
+ }
+
+}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/StatusResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/StatusResource.java
index 6feeb8d0f..1ed1bf0ff 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/StatusResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/StatusResource.java
@@ -43,9 +43,4 @@ public void doGET() throws IOException {
.write(getResponse().getOutputStream(), features);
}
- @Override
- boolean requiresAuth() {
- return true;
- }
-
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/TaskResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/TaskResource.java
index dc5ab6af9..84deec047 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/TaskResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/TaskResource.java
@@ -52,9 +52,4 @@ public void doGET() throws Exception {
}
}
- @Override
- boolean requiresAuth() {
- return true;
- }
-
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/TasksResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/api/TasksResource.java
index 4e0009df5..aae5ffe80 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/TasksResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/api/TasksResource.java
@@ -74,9 +74,4 @@ public void doPOST() throws Exception {
}
}
- @Override
- boolean requiresAuth() {
- return true;
- }
-
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/api/HealthResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/health/HealthResource.java
similarity index 75%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/api/HealthResource.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/health/HealthResource.java
index 89d323dc2..854998c9f 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/api/HealthResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/health/HealthResource.java
@@ -1,9 +1,11 @@
-package edu.illinois.library.cantaloupe.resource.api;
+package edu.illinois.library.cantaloupe.resource.health;
import com.fasterxml.jackson.databind.SerializationFeature;
import edu.illinois.library.cantaloupe.config.Configuration;
import edu.illinois.library.cantaloupe.config.Key;
import edu.illinois.library.cantaloupe.http.Method;
+import edu.illinois.library.cantaloupe.resource.AbstractResource;
+import edu.illinois.library.cantaloupe.resource.EndpointDisabledException;
import edu.illinois.library.cantaloupe.resource.JacksonRepresentation;
import edu.illinois.library.cantaloupe.status.Health;
import edu.illinois.library.cantaloupe.status.HealthChecker;
@@ -16,7 +18,7 @@
/**
* Provides health checks via the HTTP API.
*/
-public class HealthResource extends AbstractAPIResource {
+public class HealthResource extends AbstractResource {
private static final Logger LOGGER =
LoggerFactory.getLogger(HealthResource.class);
@@ -27,6 +29,17 @@ public class HealthResource extends AbstractAPIResource {
private static final Map SERIALIZATION_FEATURES =
Map.of(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true);
+ @Override
+ public void doInit() throws Exception {
+ super.doInit();
+ getResponse().setHeader("Cache-Control", "no-cache");
+
+ final Configuration config = Configuration.getInstance();
+ if (!config.getBoolean(Key.HEALTH_ENDPOINT_ENABLED, false)) {
+ throw new EndpointDisabledException();
+ }
+ }
+
@Override
protected Logger getLogger() {
return LOGGER;
@@ -57,9 +70,4 @@ public void doGET() throws IOException {
SERIALIZATION_FEATURES);
}
- @Override
- boolean requiresAuth() {
- return false;
- }
-
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/IIIFResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/IIIFResource.java
index 0bc631794..24e1b0b19 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/IIIFResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/IIIFResource.java
@@ -9,8 +9,21 @@
import edu.illinois.library.cantaloupe.operation.ScaleByPixels;
import edu.illinois.library.cantaloupe.resource.PublicResource;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+
public abstract class IIIFResource extends PublicResource {
+ protected void setLastModifiedHeader(Instant lastModified) {
+ getResponse().setHeader("Last-Modified",
+ DateTimeFormatter.RFC_1123_DATE_TIME
+ .withLocale(Locale.UK)
+ .withZone(ZoneId.systemDefault())
+ .format(lastModified));
+ }
+
/**
* When the size expressed in the endpoint URI is {@code max}, and the
* resulting image dimensions are larger than {@link Key#MAX_PIXELS}, the
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageResource.java
index 0b6ab0cbc..1bcb9fc55 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageResource.java
@@ -12,6 +12,7 @@
import edu.illinois.library.cantaloupe.operation.Scale;
import edu.illinois.library.cantaloupe.processor.Processor;
import edu.illinois.library.cantaloupe.resource.ImageRequestHandler;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -76,6 +77,13 @@ public boolean authorize() throws Exception {
return ImageResource.this.authorize();
}
+ @Override
+ public void sourceAccessed(StatResult result) {
+ if (result.getLastModified() != null) {
+ setLastModifiedHeader(result.getLastModified());
+ }
+ }
+
@Override
public void infoAvailable(Info info) {
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageInfo.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/Information.java
similarity index 90%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageInfo.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/Information.java
index 3320be6ed..62ada7bf1 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageInfo.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/Information.java
@@ -8,8 +8,8 @@
import java.util.List;
/**
- * Class whose instances are intended to be serialized to JSON for use in IIIF
- * Image Information responses.
+ * Class whose instances are intended to be serialized as JSON for use in
+ * information responses.
*
* @see IIIF Image
* API 1.1
@@ -19,7 +19,7 @@
@JsonPropertyOrder({ "@context", "@id", "width", "height", "scale_factors",
"tile_width", "tile_height", "formats", "qualities", "profile" })
@JsonInclude(JsonInclude.Include.NON_NULL)
-class ImageInfo {
+final class Information {
@JsonProperty("@context")
public final String context = "http://library.stanford.edu/iiif/image-api/1.1/context.json";
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageInfoFactory.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationFactory.java
similarity index 88%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageInfoFactory.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationFactory.java
index c0ebeba89..9a63063df 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/ImageInfoFactory.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationFactory.java
@@ -12,7 +12,10 @@
import java.util.Set;
-final class ImageInfoFactory {
+/**
+ * Builds new {@link Information} instances.
+ */
+final class InformationFactory {
private static final int MIN_SIZE = 64;
@@ -21,11 +24,11 @@ final class ImageInfoFactory {
*/
private static final int DEFAULT_MIN_TILE_SIZE = 512;
- ImageInfo newImageInfo(final String imageURI,
- final Set availableOutputFormats,
- final Info info,
- final int imageIndex,
- ScaleConstraint scaleConstraint) {
+ Information newImageInfo(final String imageURI,
+ final Set availableOutputFormats,
+ final Info info,
+ final int imageIndex,
+ ScaleConstraint scaleConstraint) {
if (scaleConstraint == null) {
scaleConstraint = new ScaleConstraint(1, 1);
}
@@ -59,7 +62,7 @@ ImageInfo newImageInfo(final String imageURI,
// Create an Info instance, which will eventually be serialized
// to JSON and sent as the response body.
- final ImageInfo imageInfo = new ImageInfo();
+ final Information imageInfo = new Information();
imageInfo.id = imageURI;
imageInfo.width = virtualSize.intWidth();
imageInfo.height = virtualSize.intHeight();
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationResource.java
index 079bc4b07..4a4c776de 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v1/InformationResource.java
@@ -14,6 +14,7 @@
import edu.illinois.library.cantaloupe.resource.ResourceException;
import edu.illinois.library.cantaloupe.resource.Route;
import edu.illinois.library.cantaloupe.resource.InformationRequestHandler;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -42,7 +43,7 @@ public Method[] getSupportedMethods() {
}
/**
- * Writes a JSON-serialized {@link ImageInfo} instance to the response.
+ * Writes a JSON-serialized {@link Information} instance to the response.
*/
@Override
public void doGET() throws Exception {
@@ -56,6 +57,14 @@ class CustomCallback implements InformationRequestHandler.Callback {
public boolean authorize() throws Exception {
return InformationResource.this.preAuthorize();
}
+
+ @Override
+ public void sourceAccessed(StatResult result) {
+ if (result.getLastModified() != null) {
+ setLastModifiedHeader(result.getLastModified());
+ }
+ }
+
@Override
public void knowAvailableOutputFormats(Set formats) {
availableOutputFormats.addAll(formats);
@@ -72,13 +81,13 @@ public void knowAvailableOutputFormats(Set formats) {
.build()) {
try {
Info info = handler.handle();
- ImageInfo iiifInfo = new ImageInfoFactory().newImageInfo(
+ Information iiifInfo = new InformationFactory().newImageInfo(
getImageURI(),
availableOutputFormats,
info,
getPageIndex(),
getMetaIdentifier().getScaleConstraint());
- addHeaders(iiifInfo);
+ addHeaders(info, iiifInfo);
new JacksonRepresentation(iiifInfo)
.write(getResponse().getOutputStream());
} catch (ResourceException e) {
@@ -92,10 +101,16 @@ public void knowAvailableOutputFormats(Set formats) {
}
}
- private void addHeaders(ImageInfo info) {
+ private void addHeaders(Info info, Information iiifInfo) {
+ // Content-Type
getResponse().setHeader("Content-Type", getNegotiatedMediaType());
+ // Link
getResponse().setHeader("Link",
- String.format("<%s>;rel=\"profile\";", info.profile));
+ String.format("<%s>;rel=\"profile\";", iiifInfo.profile));
+ // Last-Modified
+ if (info.getSerializationTimestamp() != null) {
+ setLastModifiedHeader(info.getSerializationTimestamp());
+ }
}
/**
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageResource.java
index 64aab388c..5673c699a 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageResource.java
@@ -15,6 +15,7 @@
import edu.illinois.library.cantaloupe.resource.Route;
import edu.illinois.library.cantaloupe.resource.ImageRequestHandler;
import edu.illinois.library.cantaloupe.resource.iiif.SizeRestrictedException;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,7 +24,7 @@
import java.util.Map;
/**
- * Handles IIIF Image API 2.x image requests.
+ * Handles image requests.
*
* @see Image
* Request Operations
@@ -84,6 +85,13 @@ public boolean authorize() throws Exception {
return ImageResource.this.authorize();
}
+ @Override
+ public void sourceAccessed(StatResult result) {
+ if (result.getLastModified() != null) {
+ setLastModifiedHeader(result.getLastModified());
+ }
+ }
+
@Override
public void infoAvailable(Info info) {
if (Size.ScaleMode.MAX.equals(params.getSize().getScaleMode())) {
@@ -162,8 +170,8 @@ private void validateSize(Dimension resultingSize,
Dimension virtualSize) throws SizeRestrictedException {
final var config = Configuration.getInstance();
if (config.getBoolean(Key.IIIF_RESTRICT_TO_SIZES, false)) {
- var factory = new ImageInfoFactory();
- factory.getSizes(virtualSize).stream()
+ new InformationFactory().getSizes(virtualSize)
+ .stream()
.filter(s -> s.width == resultingSize.intWidth() &&
s.height == resultingSize.intHeight())
.findAny()
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageInfo.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/Information.java
similarity index 69%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageInfo.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/Information.java
index 280e0890c..56538adbe 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageInfo.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/Information.java
@@ -7,18 +7,18 @@
import java.util.List;
/**
- *
Class whose instances are intended to be serialized to JSON for use in
- * IIIF Image Information responses.
+ *
Class whose instances are intended to be serialized as JSON for use in
+ * information responses.
*
- *
Extends Map in order to support arbitrary keys, and LinkedHashMap in
- * order to preserve key order.
+ *
Extends {@link LinkedHashMap} in order to support arbitrary keys and
+ * preserve key order.
*
- * @see IIIF Image
- * API 2.0: Image Information
+ * @see IIIF Image
+ * API 2.1: Image Information
* @see jackson-databind
* docs
*/
-class ImageInfo extends LinkedHashMap {
+class Information extends LinkedHashMap {
@JsonPropertyOrder({ "width", "height" })
public static final class Size {
@@ -29,7 +29,7 @@ public static final class Size {
public Size() {}
public Size(Integer width, Integer height) {
- this.width = width;
+ this.width = width;
this.height = height;
}
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageInfoFactory.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationFactory.java
similarity index 91%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageInfoFactory.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationFactory.java
index 3059b453b..aee1f8cc0 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/ImageInfoFactory.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationFactory.java
@@ -24,10 +24,13 @@
import java.util.Map;
import java.util.Set;
-final class ImageInfoFactory {
+/**
+ * Builds new {@link Information} instances.
+ */
+final class InformationFactory {
private static final Logger LOGGER =
- LoggerFactory.getLogger(ImageInfoFactory.class);
+ LoggerFactory.getLogger(InformationFactory.class);
/**
* Will be used if {@link Key#IIIF_MIN_SIZE} is not set.
@@ -52,7 +55,7 @@ final class ImageInfoFactory {
private double maxScale;
private int maxPixels, minSize, minTileSize;
- ImageInfoFactory() {
+ InformationFactory() {
var config = Configuration.getInstance();
maxPixels = config.getInt(Key.MAX_PIXELS, 0);
maxScale = config.getDouble(Key.MAX_SCALE, Double.MAX_VALUE);
@@ -70,11 +73,11 @@ final class ImageInfoFactory {
* list.
* @param scaleConstraint May be {@code null}.
*/
- ImageInfo newImageInfo(final Set processorOutputFormats,
- final String imageURI,
- final Info info,
- final int infoImageIndex,
- ScaleConstraint scaleConstraint) {
+ Information newImageInfo(final Set processorOutputFormats,
+ final String imageURI,
+ final Info info,
+ final int infoImageIndex,
+ ScaleConstraint scaleConstraint) {
if (scaleConstraint == null) {
scaleConstraint = new ScaleConstraint(1, 1);
}
@@ -89,7 +92,7 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
// Create a Map instance, which will eventually be serialized to JSON
// and returned in the response body.
- final ImageInfo responseInfo = new ImageInfo<>();
+ final Information responseInfo = new Information<>();
responseInfo.put("@context", "http://iiif.io/api/image/2/context.json");
responseInfo.put("@id", imageURI);
responseInfo.put("protocol", "http://iiif.io/api/image");
@@ -98,7 +101,7 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
// sizes -- this will be a 2^n series that will work for both multi-
// and monoresolution images.
- final List sizes = getSizes(virtualSize);
+ final List sizes = getSizes(virtualSize);
responseInfo.put("sizes", sizes);
// The max reduction factor is the maximum number of times the full
@@ -115,7 +118,7 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
// calculate a tile size close to minTileSize pixels.
// Otherwise, use the smallest multiple of the tile size above that
// of image resolution 0.
- final List tiles = new ArrayList<>();
+ final List tiles = new ArrayList<>();
responseInfo.put("tiles", tiles);
info.getImages().forEach(image ->
@@ -125,7 +128,7 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
minTileSize)));
for (Dimension uniqueTileSize : uniqueTileSizes) {
- final ImageInfo.Tile tile = new ImageInfo.Tile();
+ final Information.Tile tile = new Information.Tile();
tile.width = (int) Math.ceil(uniqueTileSize.width());
tile.height = (int) Math.ceil(uniqueTileSize.height());
// Add every scale factor up to 2^RFmax.
@@ -202,10 +205,10 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
* @param virtualSize Orientation-aware and {@link ScaleConstraint
* scale-constrained} full size.
*/
- List getSizes(Dimension virtualSize) {
+ List getSizes(Dimension virtualSize) {
// This will be a 2^n series that will work for both multi- and
// monoresolution images.
- final List sizes = new ArrayList<>();
+ final List sizes = new ArrayList<>();
// The min reduction factor is the smallest number of reductions that
// are required in order to fit within maxPixels.
@@ -222,7 +225,7 @@ List getSizes(Dimension virtualSize) {
i *= 2) {
final int width = (int) Math.round(virtualSize.width() / i);
final int height = (int) Math.round(virtualSize.height() / i);
- sizes.add(0, new ImageInfo.Size(width, height));
+ sizes.add(0, new Information.Size(width, height));
}
return sizes;
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationResource.java
index 55e684951..19c695694 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v2/InformationResource.java
@@ -15,13 +15,14 @@
import edu.illinois.library.cantaloupe.resource.ResourceException;
import edu.illinois.library.cantaloupe.resource.Route;
import edu.illinois.library.cantaloupe.resource.InformationRequestHandler;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.script.ScriptException;
/**
- * Handles IIIF Image API 2.x information requests.
+ * Handles information requests.
*
* @see Information
* Requests
@@ -45,7 +46,7 @@ public Method[] getSupportedMethods() {
}
/**
- * Writes a JSON-serialized {@link ImageInfo} instance to the response.
+ * Writes a JSON-serialized {@link Information} instance to the response.
*/
@Override
public void doGET() throws Exception {
@@ -63,6 +64,14 @@ class CustomCallback implements InformationRequestHandler.Callback {
public boolean authorize() throws Exception {
return InformationResource.this.preAuthorize();
}
+
+ @Override
+ public void sourceAccessed(StatResult result) {
+ if (result.getLastModified() != null) {
+ setLastModifiedHeader(result.getLastModified());
+ }
+ }
+
@Override
public void knowAvailableOutputFormats(Set formats) {
availableOutputFormats.addAll(formats);
@@ -77,9 +86,9 @@ public void knowAvailableOutputFormats(Set formats) {
.withRequestContext(getRequestContext())
.withCallback(new CustomCallback())
.build()) {
- addHeaders();
try {
Info info = handler.handle();
+ addHeaders(info);
newRepresentation(info, availableOutputFormats)
.write(getResponse().getOutputStream());
} catch (ResourceException e) {
@@ -93,8 +102,13 @@ public void knowAvailableOutputFormats(Set formats) {
}
}
- private void addHeaders() {
+ private void addHeaders(Info info) {
+ // Content-Type
getResponse().setHeader("Content-Type", getNegotiatedMediaType());
+ // Last-Modified
+ if (info.getSerializationTimestamp() != null) {
+ setLastModifiedHeader(info.getSerializationTimestamp());
+ }
}
/**
@@ -123,16 +137,16 @@ private String getNegotiatedMediaType() {
private JacksonRepresentation newRepresentation(Info info,
Set availableOutputFormats) {
- final ImageInfoFactory factory = new ImageInfoFactory();
+ final InformationFactory factory = new InformationFactory();
factory.setDelegateProxy(getDelegateProxy());
- final ImageInfo imageInfo = factory.newImageInfo(
+ final Information iiifInfo = factory.newImageInfo(
availableOutputFormats,
getImageURI(),
info,
getPageIndex(),
getMetaIdentifier().getScaleConstraint());
- return new JacksonRepresentation(imageInfo);
+ return new JacksonRepresentation(iiifInfo);
}
private JacksonRepresentation newHTTP4xxRepresentation(
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageResource.java
index 815afe9d4..e97de343c 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageResource.java
@@ -17,6 +17,7 @@
import edu.illinois.library.cantaloupe.resource.ScaleRestrictedException;
import edu.illinois.library.cantaloupe.resource.ImageRequestHandler;
import edu.illinois.library.cantaloupe.resource.iiif.SizeRestrictedException;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -25,7 +26,7 @@
import java.util.Map;
/**
- * Handles IIIF Image API 3.x image requests.
+ * Handles image requests.
*
* @see Image
* Requests
@@ -87,6 +88,13 @@ public boolean authorize() throws Exception {
return ImageResource.this.authorize();
}
+ @Override
+ public void sourceAccessed(StatResult result) {
+ if (result.getLastModified() != null) {
+ setLastModifiedHeader(result.getLastModified());
+ }
+ }
+
@Override
public void infoAvailable(Info info) {
if (Size.Type.MAX.equals(params.getSize().getType())) {
@@ -204,8 +212,8 @@ private void validateSize(Dimension virtualSize,
Dimension resultingSize) throws SizeRestrictedException {
final Configuration config = Configuration.getInstance();
if (config.getBoolean(Key.IIIF_RESTRICT_TO_SIZES, false)) {
- var factory = new ImageInfoFactory();
- factory.getSizes(virtualSize).stream()
+ new InformationFactory().getSizes(virtualSize)
+ .stream()
.filter(s -> s.width == resultingSize.intWidth() &&
s.height == resultingSize.intHeight())
.findAny()
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageInfo.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/Information.java
similarity index 87%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageInfo.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/Information.java
index 1f08ff38e..67dcaf22f 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageInfo.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/Information.java
@@ -7,8 +7,8 @@
import java.util.List;
/**
- *
Class whose instances are intended to be serialized to JSON for use in
- * IIIF Image Information responses.
+ *
Class whose instances are intended to be serialized as JSON for use in
+ * information responses.
*
*
Extends {@link LinkedHashMap} in order to support arbitrary keys and
* preserve key order.
@@ -16,7 +16,7 @@
* @see IIIF
* Image API 3.0: Technical Properties
*/
-class ImageInfo extends LinkedHashMap {
+class Information extends LinkedHashMap {
@JsonPropertyOrder({ "width", "height" })
public static final class Size {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageInfoFactory.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationFactory.java
similarity index 90%
rename from src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageInfoFactory.java
rename to src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationFactory.java
index 800e0d1cd..4c10dfbff 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/ImageInfoFactory.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationFactory.java
@@ -22,10 +22,13 @@
import java.util.Set;
import java.util.stream.Collectors;
-final class ImageInfoFactory {
+/**
+ * Builds new {@link Information} instances.
+ */
+final class InformationFactory {
private static final Logger LOGGER =
- LoggerFactory.getLogger(ImageInfoFactory.class);
+ LoggerFactory.getLogger(InformationFactory.class);
private static final String CONTEXT =
"http://iiif.io/api/image/3/context.json";
@@ -50,7 +53,7 @@ final class ImageInfoFactory {
private long maxPixels;
private int minSize, minTileSize;
- ImageInfoFactory() {
+ InformationFactory() {
var config = Configuration.getInstance();
maxPixels = config.getInt(Key.MAX_PIXELS, 0);
maxScale = config.getDouble(Key.MAX_SCALE, Double.MAX_VALUE);
@@ -67,11 +70,11 @@ final class ImageInfoFactory {
* list.
* @param scaleConstraint May be {@code null}.
*/
- ImageInfo newImageInfo(final Set processorOutputFormats,
- final String imageURI,
- final Info info,
- final int infoImageIndex,
- ScaleConstraint scaleConstraint) {
+ Information newImageInfo(final Set processorOutputFormats,
+ final String imageURI,
+ final Info info,
+ final int infoImageIndex,
+ ScaleConstraint scaleConstraint) {
if (scaleConstraint == null) {
scaleConstraint = new ScaleConstraint(1, 1);
}
@@ -86,7 +89,7 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
// Create an instance, which will later be serialized as JSON and
// returned in the response body.
- final ImageInfo responseInfo = new ImageInfo<>();
+ final Information responseInfo = new Information<>();
responseInfo.put("@context", CONTEXT);
responseInfo.put("id", imageURI);
responseInfo.put("type", TYPE);
@@ -162,10 +165,10 @@ ImageInfo newImageInfo(final Set processorOutputFormats,
* @param virtualSize Orientation-aware and {@link ScaleConstraint
* scale-constrained} full size.
*/
- List getSizes(Dimension virtualSize) {
+ List getSizes(Dimension virtualSize) {
// This will be a 2^n series that will work for both multi- and
// monoresolution images.
- final List sizes = new ArrayList<>();
+ final List sizes = new ArrayList<>();
// The min reduction factor is the smallest number of reductions that
// are required in order to fit within maxPixels.
@@ -182,7 +185,7 @@ List getSizes(Dimension virtualSize) {
i *= 2) {
final int width = (int) Math.round(virtualSize.width() / i);
final int height = (int) Math.round(virtualSize.height() / i);
- sizes.add(0, new ImageInfo.Size(width, height));
+ sizes.add(0, new Information.Size(width, height));
}
return sizes;
}
@@ -199,10 +202,10 @@ List getSizes(Dimension virtualSize) {
* deliver (which may or may not match the physical tile size or a multiple
* of it).
*/
- List getTiles(Dimension virtualSize,
- Orientation orientation,
- List images) {
- final List tiles = new ArrayList<>();
+ List getTiles(Dimension virtualSize,
+ Orientation orientation,
+ List images) {
+ final List tiles = new ArrayList<>();
final Set uniqueTileSizes = new HashSet<>();
images.forEach(image ->
@@ -217,7 +220,7 @@ List getTiles(Dimension virtualSize,
ImageInfoUtil.maxReductionFactor(virtualSize, minSize);
for (Dimension uniqueTileSize : uniqueTileSizes) {
- final ImageInfo.Tile tile = new ImageInfo.Tile();
+ final Information.Tile tile = new Information.Tile();
tile.width = (int) Math.ceil(uniqueTileSize.width());
tile.height = (int) Math.ceil(uniqueTileSize.height());
// Add every scale factor up to 2^RFmax.
diff --git a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationResource.java b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationResource.java
index 383b1067f..125130cf9 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationResource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/resource/iiif/v3/InformationResource.java
@@ -15,6 +15,7 @@
import edu.illinois.library.cantaloupe.resource.ResourceException;
import edu.illinois.library.cantaloupe.resource.Route;
import edu.illinois.library.cantaloupe.resource.InformationRequestHandler;
+import edu.illinois.library.cantaloupe.source.StatResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -45,7 +46,7 @@ public Method[] getSupportedMethods() {
}
/**
- * Writes a JSON-serialized {@link ImageInfo} instance to the response.
+ * Writes a JSON-serialized {@link Information} instance to the response.
*/
@Override
public void doGET() throws Exception {
@@ -63,6 +64,14 @@ class CustomCallback implements InformationRequestHandler.Callback {
public boolean authorize() throws Exception {
return InformationResource.this.preAuthorize();
}
+
+ @Override
+ public void sourceAccessed(StatResult result) {
+ if (result.getLastModified() != null) {
+ setLastModifiedHeader(result.getLastModified());
+ }
+ }
+
@Override
public void knowAvailableOutputFormats(Set formats) {
availableOutputFormats.addAll(formats);
@@ -77,9 +86,9 @@ public void knowAvailableOutputFormats(Set formats) {
.withRequestContext(getRequestContext())
.withCallback(new CustomCallback())
.build()) {
- addHeaders();
try {
Info info = handler.handle();
+ addHeaders(info);
newRepresentation(info, availableOutputFormats)
.write(getResponse().getOutputStream());
} catch (ResourceException e) {
@@ -93,8 +102,13 @@ public void knowAvailableOutputFormats(Set formats) {
}
}
- private void addHeaders() {
+ private void addHeaders(Info info) {
+ // Content-Type
getResponse().setHeader("Content-Type", getNegotiatedContentType());
+ // Last-Modified
+ if (info.getSerializationTimestamp() != null) {
+ setLastModifiedHeader(info.getSerializationTimestamp());
+ }
}
/**
@@ -125,16 +139,16 @@ private String getNegotiatedContentType() {
private JacksonRepresentation newRepresentation(Info info,
Set availableOutputFormats) {
- final ImageInfoFactory factory = new ImageInfoFactory();
+ final InformationFactory factory = new InformationFactory();
factory.setDelegateProxy(getDelegateProxy());
- final ImageInfo imageInfo = factory.newImageInfo(
+ final Information iiifInfo = factory.newImageInfo(
availableOutputFormats,
getImageURI(),
info,
getPageIndex(),
getMetaIdentifier().getScaleConstraint());
- return new JacksonRepresentation(imageInfo);
+ return new JacksonRepresentation(iiifInfo);
}
private JacksonRepresentation newHTTP4xxRepresentation(
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/AzureStorageSource.java b/src/main/java/edu/illinois/library/cantaloupe/source/AzureStorageSource.java
index cf3a57cbc..af45a0aa2 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/AzureStorageSource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/AzureStorageSource.java
@@ -226,8 +226,11 @@ private static synchronized CloudBlobClient getClientInstance() {
}
@Override
- public void checkAccess() throws IOException {
- fetchBlob();
+ public StatResult stat() throws IOException {
+ CloudBlockBlob blob = fetchBlob();
+ StatResult result = new StatResult();
+ result.setLastModified(blob.getProperties().getLastModified().toInstant());
+ return result;
}
private CloudBlockBlob fetchBlob() throws IOException {
@@ -275,6 +278,7 @@ String getBlobKey() throws IOException {
if (objectKey == null) {
final LookupStrategy strategy =
LookupStrategy.from(Key.AZURESTORAGESOURCE_LOOKUP_STRATEGY);
+ //noinspection SwitchStatementWithTooFewBranches
switch (strategy) {
case DELEGATE_SCRIPT:
try {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/FilesystemSource.java b/src/main/java/edu/illinois/library/cantaloupe/source/FilesystemSource.java
index d2b4a4bb2..ab6cafd6a 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/FilesystemSource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/FilesystemSource.java
@@ -118,7 +118,7 @@ public T next() {
private Path path;
@Override
- public void checkAccess() throws IOException {
+ public StatResult stat() throws IOException {
final Path file = getFile();
if (!Files.exists(file)) {
throw new NoSuchFileException("Failed to resolve " +
@@ -126,6 +126,9 @@ public void checkAccess() throws IOException {
} else if (!Files.isReadable(file)) {
throw new AccessDeniedException("File is not readable: " + file);
}
+ StatResult result = new StatResult();
+ result.setLastModified(Files.getLastModifiedTime(file).toInstant());
+ return result;
}
/**
@@ -139,6 +142,7 @@ public Path getFile() throws IOException {
if (path == null) {
final LookupStrategy strategy =
LookupStrategy.from(Key.FILESYSTEMSOURCE_LOOKUP_STRATEGY);
+ //noinspection SwitchStatementWithTooFewBranches
switch (strategy) {
case DELEGATE_SCRIPT:
try {
@@ -169,7 +173,7 @@ private Path getPathWithBasicStrategy() {
final String suffix =
config.getString(Key.FILESYSTEMSOURCE_PATH_SUFFIX, "");
final Identifier sanitizedId = sanitizedIdentifier();
- return Paths.get(prefix + sanitizedId.toString() + suffix);
+ return Paths.get(prefix + sanitizedId + suffix);
}
/**
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/HTTPRequestInfo.java b/src/main/java/edu/illinois/library/cantaloupe/source/HTTPRequestInfo.java
index fa3d2033d..1b4bebc0d 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/HTTPRequestInfo.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/HTTPRequestInfo.java
@@ -10,27 +10,7 @@ class HTTPRequestInfo {
private final Headers headers = new Headers();
private String uri, username, secret;
-
- HTTPRequestInfo(String uri) {
- this.uri = uri;
- }
-
- HTTPRequestInfo(String uri, String username, String secret) {
- this(uri);
- this.username = username;
- this.secret = secret;
- }
-
- HTTPRequestInfo(String uri,
- String username,
- String secret,
- Map headers) {
- this(uri, username, secret);
- if (headers != null) {
- headers.forEach((key, value) ->
- this.headers.add(key, value.toString()));
- }
- }
+ private boolean isSendingHeadRequest = true;
String getBasicAuthToken() {
if (getUsername() != null && getSecret() != null) {
@@ -57,6 +37,35 @@ String getUsername() {
return username;
}
+ boolean isSendingHeadRequest() {
+ return isSendingHeadRequest;
+ }
+
+ void setHeaders(Map headers) {
+ if (headers != null) {
+ headers.forEach((key, value) ->
+ this.headers.add(key, value.toString()));
+ } else {
+ this.headers.clear();
+ }
+ }
+
+ void setSecret(String secret) {
+ this.secret = secret;
+ }
+
+ void setSendingHeadRequest(boolean isUsingHeadRequest) {
+ this.isSendingHeadRequest = isUsingHeadRequest;
+ }
+
+ void setURI(String uri) {
+ this.uri = uri;
+ }
+
+ void setUsername(String username) {
+ this.username = username;
+ }
+
@Override
public String toString() {
return getURI() + "";
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/HttpSource.java b/src/main/java/edu/illinois/library/cantaloupe/source/HttpSource.java
index 6d3ef2293..e9c2af51d 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/HttpSource.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/HttpSource.java
@@ -19,6 +19,8 @@
import javax.net.ssl.X509TrustManager;
import javax.script.ScriptException;
import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.AccessDeniedException;
@@ -28,9 +30,14 @@
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.TimeUnit;
@@ -66,12 +73,16 @@
* issues the following server requests:
*
*
- *
{@literal HEAD}
- *
If server supports ranges:
+ *
If {@link Key#HTTPSOURCE_SEND_HEAD_REQUESTS} is {@code true}, or
+ * the delegate method returns {@code true} for the equivalent key, a
+ * {@code HEAD} request. Otherwise, a ranged {@code GET} request specifying
+ * a small range of the beginning of the resource.
+ *
If a {@code HEAD} request was sent:
*
- *
If {@link FormatIterator#next()} } needs to check magic
+ *
If {@link FormatIterator#next()} needs to check magic bytes,
+ * and the server supports ranges:
*
- *
Ranged {@literal GET}
+ *
Ranged {@code GET}
*
*
*
If {@link HTTPStreamFactory#newSeekableStream()} is used:
@@ -83,7 +94,7 @@
*
*
Else if {@link HTTPStreamFactory#newInputStream()} is used:
*
- *
{@literal GET} to retrieve the full image bytes
+ *
{@code GET} to retrieve the full image bytes
*
*
*
@@ -112,18 +123,31 @@
class HttpSource extends AbstractSource implements Source {
/**
- * Encapsulates the status code and headers of a HEAD response.
+ * Encapsulates the status code, headers, and body (if available) of a
+ * {code HEAD} or ranged {@code GET} response. The range specifies a small
+ * part of the beginning of the resource to use for the purpose of
+ * inferring its format.
*/
- private static class HEADResponseInfo {
+ private static class ResourceInfo {
- int status;
- Headers headers;
+ private String requestMethod;
+ private int status;
+ private Headers headers;
- static HEADResponseInfo fromResponse(Response response)
- throws IOException {
- HEADResponseInfo info = new HEADResponseInfo();
- info.status = response.code();
- info.headers = response.headers();
+ /**
+ * Response entity with a maximum length of {@link #RANGE_LENGTH}.
+ */
+ private byte[] entity;
+
+ static ResourceInfo fromResponse(Response response) throws IOException {
+ ResourceInfo info = new ResourceInfo();
+ info.requestMethod = response.request().method();
+ info.status = response.code();
+ info.headers = response.headers();
+ if ("GET".equals(response.request().method()) &&
+ response.body() != null) {
+ info.entity = response.body().bytes();
+ }
return info;
}
@@ -131,37 +155,13 @@ boolean acceptsRanges() {
return "bytes".equals(headers.get("Accept-Ranges"));
}
- long getContentLength() {
+ long contentLength() {
String value = headers.get("Content-Length");
return (value != null) ? Long.parseLong(value) : 0;
}
- }
-
- /**
- * Encapsulates the status code, headers, and body of a ranged GET
- * response. The range specifies a small part of the beginning of the
- * resource to use for the purpose of inferring its format.
- */
- private static class RangedGETResponseInfo extends HEADResponseInfo {
-
- private static final int RANGE_LENGTH = 32;
-
- /**
- * Ranged response entity, with a maximum length of {@link
- * #RANGE_LENGTH}.
- */
- private byte[] entity;
-
- static RangedGETResponseInfo fromResponse(Response response)
- throws IOException {
- RangedGETResponseInfo info = new RangedGETResponseInfo();
- info.status = response.code();
- info.headers = response.headers();
- if (response.body() != null) {
- info.entity = response.body().bytes();
- }
- return info;
+ String contentType() {
+ return headers.get("Content-Type");
}
Format detectFormat() throws IOException {
@@ -175,6 +175,18 @@ Format detectFormat() throws IOException {
return format;
}
+ Instant lastModified() {
+ String str = headers.get("Last-Modified");
+ if (str != null) {
+ TemporalAccessor ta = DateTimeFormatter.RFC_1123_DATE_TIME
+ .withLocale(Locale.UK)
+ .withZone(ZoneId.systemDefault())
+ .parse(str);
+ return Instant.from(ta);
+ }
+ return null;
+ }
+
}
/**
@@ -183,13 +195,13 @@ Format detectFormat() throws IOException {
* extension, the format is inferred from that.
*
Otherwise, if the identifier contains a recognized filename
* extension, the format is inferred from that.
- *
Otherwise, if a {@literal Content-Type} header is present in the
- * {@link #fetchHEADResponseInfo() HEAD response}, and its value is
- * specific enough (not {@literal application/octet-stream}, for
+ *
Otherwise, if a {@code Content-Type} header is present in the
+ * {@link #getResourceInfo HEAD response}, and its value is
+ * specific enough (not {@code application/octet-stream}, for
* example), a format is inferred from that.
- *
Otherwise, if the {@literal HEAD} response contains an {@literal
- * Accept-Ranges: bytes} header, a {@literal GET} request is sent with
- * a {@literal Range} header specifying a small range of data from the
+ *
Otherwise, if the {@literal HEAD} response contains an {@code
+ * Accept-Ranges: bytes} header, a {@code GET} request is sent with a
+ * {@code Range} header specifying a small range of data from the
* beginning of the resource, and a format is inferred from the magic
* bytes in the response entity.
*
Otherwise, {@link Format#UNKNOWN} is returned.
@@ -200,36 +212,41 @@ Format detectFormat() throws IOException {
class FormatIterator implements Iterator {
/**
- * Infers a {@link Format} based on the {@literal Content-Type} header in
- * the {@link #fetchHEADResponseInfo() HEAD response}.
+ * Infers a {@link Format} based on the {@code Content-Type} header in
+ * the initial response..
*/
private class ContentTypeHeaderChecker implements FormatChecker {
/**
- * @return Format from the {@literal Content-Type} header, or {@link
+ * @return Format from the {@code Content-Type} header, or {@link
* Format#UNKNOWN} if that header is missing or invalid.
*/
@Override
public Format check() {
try {
final HTTPRequestInfo requestInfo = getRequestInfo();
- final HEADResponseInfo responseInfo = fetchHEADResponseInfo();
+ final ResourceInfo resourceInfo = getResourceInfo();
- if (responseInfo.status >= 200 && responseInfo.status < 300) {
- String field = responseInfo.headers.get("Content-Type");
- if (field != null) {
- Format format = MediaType.fromContentType(field).toFormat();
+ if (resourceInfo.status >= 200 && resourceInfo.status < 300) {
+ String value = resourceInfo.contentType();
+ if (value != null) {
+ Format format = MediaType.fromContentType(value).toFormat();
if (Format.UNKNOWN.equals(format)) {
- LOGGER.debug("Unrecognized Content-Type header value for HEAD {}: {}",
- requestInfo.getURI(), field);
+ LOGGER.debug("Unrecognized Content-Type header value for {} {}: {}",
+ resourceInfo.requestMethod,
+ requestInfo.getURI(),
+ value);
}
return format;
} else {
- LOGGER.debug("No Content-Type header for HEAD {}",
+ LOGGER.debug("No Content-Type header for {} {}",
+ resourceInfo.requestMethod,
requestInfo.getURI());
}
} else {
- LOGGER.debug("HEAD {} returned status {}",
- requestInfo.getURI(), responseInfo.status);
+ LOGGER.debug("{} {} returned status {}",
+ resourceInfo.requestMethod,
+ requestInfo.getURI(),
+ resourceInfo.status);
}
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
@@ -240,10 +257,12 @@ public Format check() {
private class ByteChecker implements FormatChecker {
/**
- * If the {@link #fetchHEADResponseInfo() HEAD response} contains an
- * {@literal Accept-Ranges: bytes} header, issues an HTTP {@literal GET}
- * request for a small {@literal Range} of the beginning of the resource
- * and checks the magic bytes in the response body.
+ * If the {@link #getResourceInfo initial response} is from a
+ * {@code HEAD} request, issues an HTTP {@code GET} request for a
+ * small range of the beginning of the resource. (If it is a {@code
+ * GET} response, that data has already been received.) Then, a
+ * source format is inferred from the magic bytes in the response
+ * entity.
*
* @return Inferred source format, or {@link Format#UNKNOWN}.
*/
@@ -251,28 +270,43 @@ private class ByteChecker implements FormatChecker {
public Format check() {
try {
final HTTPRequestInfo requestInfo = getRequestInfo();
- if (fetchHEADResponseInfo().acceptsRanges()) {
- final RangedGETResponseInfo responseInfo
- = fetchRangedGETResponseInfo();
- if (responseInfo.status >= 200 && responseInfo.status < 300) {
- Format format = responseInfo.detectFormat();
- if (!Format.UNKNOWN.equals(format)) {
- LOGGER.debug("Inferred {} format from magic bytes for GET {}",
- format, requestInfo.getURI());
- return format;
- } else {
- LOGGER.debug("Unable to infer a format from magic bytes for GET {}",
- requestInfo.getURI());
- }
+ // If a request hasn't yet been sent, send one. This may be
+ // a HEAD or a ranged GET. It's not safe to send a ranged
+ // GET without first checking (via HEAD) whether the
+ // resource supports ranges--unless we are told via the
+ // configuration not to send HEADs.
+ if (resourceInfo == null) {
+ resourceInfo = getResourceInfo();
+ }
+ // If it was a HEAD, we need to know whether the resource
+ // supports ranged requests. If it does, send one.
+ if ("HEAD".equals(resourceInfo.requestMethod)) {
+ if (resourceInfo.acceptsRanges()) {
+ resourceInfo = fetchResourceInfoViaGET();
} else {
- LOGGER.debug("GET {} returned status {}",
- requestInfo.getURI(), responseInfo.status);
+ LOGGER.debug("Server did not supply an " +
+ "`Accept-Ranges: bytes` header in response " +
+ "to HEAD {}, and all other attempts to "+
+ "infer a format failed.",
+ requestInfo.getURI());
+ return Format.UNKNOWN;
+ }
+ }
+ if (resourceInfo.status >= 200 && resourceInfo.status < 300) {
+ Format format = resourceInfo.detectFormat();
+ if (!Format.UNKNOWN.equals(format)) {
+ LOGGER.debug("Inferred {} format from magic bytes for GET {}",
+ format, requestInfo.getURI());
+ return format;
+ } else {
+ LOGGER.debug("Unable to infer a format from magic bytes for GET {}",
+ requestInfo.getURI());
}
} else {
- LOGGER.info("Server did not supply an " +
- "`Accept-Ranges: bytes` header for HEAD {}, and all " +
- "other attempts to infer a format failed.",
- requestInfo.getURI());
+ LOGGER.debug("{} {} returned status {}",
+ resourceInfo.requestMethod,
+ requestInfo.getURI(),
+ resourceInfo.status);
}
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
@@ -336,29 +370,38 @@ public Format check() {
static final Logger LOGGER = LoggerFactory.getLogger(HttpSource.class);
+ static final String USER_AGENT = String.format(
+ "%s/%s (%s/%s; java/%s; %s/%s)",
+ HttpSource.class.getSimpleName(),
+ Application.getVersion(),
+ Application.getName(),
+ Application.getVersion(),
+ System.getProperty("java.version"),
+ System.getProperty("os.name"),
+ System.getProperty("os.version"));
+
private static final int DEFAULT_REQUEST_TIMEOUT = 30;
+ private static final int RANGE_LENGTH = 32;
private static OkHttpClient httpClient;
/**
- * Cached {@link #fetchHEADResponseInfo() HEAD response info}.
- */
- private HEADResponseInfo headResponseInfo;
-
- /**
- * Cached {@link #fetchRangedGETResponseInfo() ranged GET response info}.
+ * Cached by {@link #getRequestInfo()}.
*/
- private RangedGETResponseInfo rangedGETResponseInfo;
+ private HTTPRequestInfo requestInfo;
/**
- * Cached by {@link #getRequestInfo()}.
+ * Cached {@link #getResourceInfo resource info} from the initial request,
+ * which may be either a {@code HEAD} or ranged {@code GET}, depending on
+ * the configuration.
*/
- private HTTPRequestInfo requestInfo;
+ private ResourceInfo resourceInfo;
- private final FormatIterator formatIterator = new FormatIterator<>();
+ private final FormatIterator formatIterator =
+ new FormatIterator<>();
/**
- * @return Shared instance.
+ * @return Already-initialized instance shared by all threads.
*/
static synchronized OkHttpClient getHTTPClient() {
if (httpClient == null) {
@@ -367,12 +410,24 @@ static synchronized OkHttpClient getHTTPClient() {
.connectTimeout(getRequestTimeout().getSeconds(), TimeUnit.SECONDS)
.readTimeout(getRequestTimeout().getSeconds(), TimeUnit.SECONDS)
.writeTimeout(getRequestTimeout().getSeconds(), TimeUnit.SECONDS);
-
final Configuration config = Configuration.getInstance();
- final boolean allowInsecure = config.getBoolean(
- Key.HTTPSOURCE_ALLOW_INSECURE, false);
- if (allowInsecure) {
+ final String proxyHost =
+ config.getString(Key.HTTPSOURCE_HTTP_PROXY_HOST, "");
+ if (!proxyHost.isBlank()) {
+ final int proxyPort =
+ config.getInt(Key.HTTPSOURCE_HTTP_PROXY_PORT);
+ if (proxyPort == 0) {
+ throw new RuntimeException("Proxy port setting " +
+ Key.HTTPSOURCE_HTTP_PROXY_PORT + " must be set");
+ }
+ LOGGER.debug("Using HTTP proxy: {}:{}", proxyHost, proxyPort);
+ Proxy httpProxy = new Proxy(Proxy.Type.HTTP,
+ new InetSocketAddress(proxyHost, proxyPort));
+ builder.proxy(httpProxy);
+ }
+
+ if (config.getBoolean(Key.HTTPSOURCE_ALLOW_INSECURE, false)) {
try {
X509TrustManager[] tm = new X509TrustManager[]{
new X509TrustManager() {
@@ -412,17 +467,6 @@ private static Duration getRequestTimeout() {
return Duration.ofSeconds(timeout);
}
- static String getUserAgent() {
- return String.format("%s/%s (%s/%s; java/%s; %s/%s)",
- HttpSource.class.getSimpleName(),
- Application.getVersion(),
- Application.getName(),
- Application.getVersion(),
- System.getProperty("java.version"),
- System.getProperty("os.name"),
- System.getProperty("os.version"));
- }
-
/**
* @see #request(HTTPRequestInfo, String, Map)
*/
@@ -447,7 +491,7 @@ static Response request(HTTPRequestInfo requestInfo,
Request.Builder builder = new Request.Builder()
.method(method, null)
.url(requestInfo.getURI())
- .addHeader("User-Agent", getUserAgent());
+ .addHeader("User-Agent", USER_AGENT);
// Add credentials.
if (requestInfo.getUsername() != null &&
requestInfo.getSecret() != null) {
@@ -477,10 +521,9 @@ static String toString(Headers headers) {
}
@Override
- public void checkAccess() throws IOException {
- fetchHEADResponseInfo();
-
- final int status = headResponseInfo.status;
+ public StatResult stat() throws IOException {
+ ResourceInfo info = getResourceInfo();
+ final int status = info.status;
if (status >= 400) {
final String statusLine = "HTTP " + status;
if (status == 404 || status == 410) { // not found or gone
@@ -491,6 +534,9 @@ public void checkAccess() throws IOException {
throw new IOException(statusLine);
}
}
+ StatResult result = new StatResult();
+ result.setLastModified(info.lastModified());
+ return result;
}
@Override
@@ -498,72 +544,46 @@ public FormatIterator getFormatIterator() {
return formatIterator;
}
- @Override
- public StreamFactory newStreamFactory() throws IOException {
- HTTPRequestInfo info;
- try {
- info = getRequestInfo();
- } catch (IOException e) {
- throw e;
- } catch (Exception e) {
- LOGGER.error("newStreamFactory(): {}", e.getMessage());
- throw new IOException(e);
- }
-
- if (info != null) {
- LOGGER.debug("Resolved {} to {}", identifier, info.getURI());
- fetchHEADResponseInfo();
- return new HTTPStreamFactory(
- info,
- headResponseInfo.getContentLength(),
- headResponseInfo.acceptsRanges());
- }
- return null;
- }
-
/**
- *
Issues a {@literal HEAD} request and caches parts of the response in
- * {@link #headResponseInfo}.
+ * Issues a {@code HEAD} or ranged {@code GET} request (depending on the
+ * configuration) and caches the result in {@link #resourceInfo}.
*/
- private HEADResponseInfo fetchHEADResponseInfo() throws IOException {
- if (headResponseInfo == null) {
- try (Response response = request("HEAD")) {
- headResponseInfo = HEADResponseInfo.fromResponse(response);
+ private ResourceInfo getResourceInfo() throws IOException {
+ if (resourceInfo == null) {
+ try {
+ requestInfo = getRequestInfo();
+ if (requestInfo.isSendingHeadRequest()) {
+ fetchResourceInfoViaHEAD();
+ } else {
+ fetchResourceInfoViaGET();
+ }
+ } catch (Exception e) {
+ LOGGER.error("fetchResourceInfo(): {}", e.getMessage());
+ throw new IOException(e.getMessage(), e);
}
}
- return headResponseInfo;
+ return resourceInfo;
}
- /**
- *
Issues a {@literal GET} request specifying a small range of data and
- * caches parts of the response in {@link #rangedGETResponseInfo}.
Maps an identifier to a binary/BLOB field in a relational database.
*
- *
A custom schema is not required; any schema will work. However, several
+ *
A custom schema is not required; most schemas will work. However, several
* delegate methods must be implemented in order to obtain the information
* needed to run the SQL queries.
*
@@ -211,23 +213,11 @@ static synchronized Connection getConnection() throws SQLException {
}
@Override
- public void checkAccess() throws IOException {
- try (Connection connection = getConnection()) {
- final String sql = getLookupSQL();
-
- // Check that the image exists.
- try (PreparedStatement statement = connection.prepareStatement(sql)) {
- statement.setString(1, getDatabaseIdentifier());
- LOGGER.debug(sql);
- try (ResultSet result = statement.executeQuery()) {
- if (!result.next()) {
- throw new NoSuchFileException(sql);
- }
- }
- }
- } catch (ScriptException | SQLException e) {
- throw new IOException(e);
- }
+ public StatResult stat() throws IOException {
+ Instant lastModified = getLastModified();
+ StatResult result = new StatResult();
+ result.setLastModified(lastModified);
+ return result;
}
/**
@@ -243,6 +233,41 @@ public FormatIterator getFormatIterator() {
return formatIterator;
}
+ Instant getLastModified() throws IOException {
+ try {
+ String methodResult = getDelegateProxy().getJdbcSourceLastModified();
+ if (methodResult != null) {
+ // the delegate method result may be an ISO 8601 string, or an
+ // SQL statement to look it up.
+ if (methodResult.toUpperCase().startsWith("SELECT")) {
+ // It's called readability, IntelliJ!
+ //noinspection UnnecessaryLocalVariable
+ final String sql = methodResult;
+ LOGGER.debug(sql);
+ try (Connection connection = getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, getDatabaseIdentifier());
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (resultSet.next()) {
+ Timestamp value = resultSet.getTimestamp(1);
+ if (value != null) {
+ return value.toInstant();
+ }
+ } else {
+ throw new NoSuchFileException(sql);
+ }
+ }
+ }
+ } else {
+ return Instant.parse(methodResult);
+ }
+ }
+ } catch (SQLException | ScriptException e) {
+ throw new IOException(e);
+ }
+ return null;
+ }
+
/**
* @return Result of the {@link DelegateMethod#JDBCSOURCE_LOOKUP_SQL}
* method.
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/S3HTTPImageInputStreamClient.java b/src/main/java/edu/illinois/library/cantaloupe/source/S3HTTPImageInputStreamClient.java
index 6c9e49d62..3d06f7807 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/S3HTTPImageInputStreamClient.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/S3HTTPImageInputStreamClient.java
@@ -36,7 +36,7 @@ class S3HTTPImageInputStreamClient implements HTTPImageInputStreamClient {
@Override
public Response sendHEADRequest() throws IOException {
- final S3Client client = S3Source.getClientInstance();
+ final S3Client client = S3Source.getClientInstance(objectInfo);
final String bucket = objectInfo.getBucketName();
final String key = objectInfo.getKey();
try {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/S3ObjectInfo.java b/src/main/java/edu/illinois/library/cantaloupe/source/S3ObjectInfo.java
index cfaa8afe6..bf11539ca 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/S3ObjectInfo.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/S3ObjectInfo.java
@@ -1,19 +1,32 @@
package edu.illinois.library.cantaloupe.source;
+/**
+ * Contains information needed to access an object in S3.
+ */
final class S3ObjectInfo {
- private String bucketName, key;
+ private String region, endpoint, accessKeyID, secretAccessKey, bucketName,
+ key;
private long length = -1;
- S3ObjectInfo(String key, String bucketName) {
- this.key = key;
- this.bucketName = bucketName;
+ /**
+ * @return Access key ID. May be {@code null}.
+ */
+ String getAccessKeyID() {
+ return accessKeyID;
}
String getBucketName() {
return bucketName;
}
+ /**
+ * @return Service endpoint URI. May be {@code null}.
+ */
+ String getEndpoint() {
+ return endpoint;
+ }
+
String getKey() {
return key;
}
@@ -22,13 +35,61 @@ long getLength() {
return length;
}
+ /**
+ * @return Endpoint AWS region. Only used by AWS endpoints. May be {@code
+ * null}.
+ */
+ String getRegion() {
+ return region;
+ }
+
+ /**
+ * @return Secret access key. May be {@code null}.
+ */
+ String getSecretAccessKey() {
+ return secretAccessKey;
+ }
+
+ void setAccessKeyID(String accessKeyID) {
+ this.accessKeyID = accessKeyID;
+ }
+
+ void setBucketName(String bucketName) {
+ this.bucketName = bucketName;
+ }
+
+ void setEndpoint(String endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ void setKey(String key) {
+ this.key = key;
+ }
+
void setLength(long length) {
this.length = length;
}
+ void setRegion(String region) {
+ this.region = region;
+ }
+
+ void setSecretAccessKey(String secretAccessKey) {
+ this.secretAccessKey = secretAccessKey;
+ }
+
@Override
public String toString() {
- return getBucketName() + "/" + getKey();
+ StringBuilder b = new StringBuilder();
+ b.append("[endpoint: ").append(getEndpoint()).append("] ");
+ b.append("[region: ").append(getRegion()).append("] ");
+ String tmp = getAccessKeyID() != null ? "******" : "null";
+ b.append("[accessKeyID: ").append(tmp).append("] ");
+ tmp = getSecretAccessKey() != null ? "******" : "null";
+ b.append("[secretAccessKey: ").append(tmp).append("] ");
+ b.append("[bucket: ").append(getBucketName()).append("] ");
+ b.append("[key: ").append(getKey()).append("]");
+ return b.toString();
}
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/S3Source.java b/src/main/java/edu/illinois/library/cantaloupe/source/S3Source.java
index 6b18d98a0..93db8621f 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/S3Source.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/S3Source.java
@@ -28,6 +28,8 @@
import java.net.URISyntaxException;
import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;
+import java.time.Instant;
+import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -79,13 +81,12 @@
*
*
* @author Alex Dolski UIUC
- * @see
- * Minio Java Client API Reference
*/
final class S3Source extends AbstractSource implements Source {
private static class S3ObjectAttributes {
String contentType;
+ Instant lastModified;
long length;
}
@@ -189,11 +190,16 @@ public T next() {
LoggerFactory.getLogger(S3Source.class);
/**
- * Range used to infer the source image format.
+ * Byte range used to infer the source image format.
*/
private static final Range FORMAT_INFERENCE_RANGE = new Range(0, 32);
- private static S3Client client;
+ /**
+ * The keys are endpoint URIs. The default client's key is {@code null}.
+ * This is not thread-safe, so should only be accessed via {@link
+ * #getClientInstance(S3ObjectInfo)}.
+ */
+ private static final Map CLIENTS = new HashMap<>();
/**
* Cached by {@link #getObjectInfo()}.
@@ -207,25 +213,44 @@ public T next() {
private FormatIterator formatIterator = new FormatIterator<>();
- static synchronized S3Client getClientInstance() {
+ static synchronized S3Client getClientInstance(S3ObjectInfo info) {
+ String endpoint = info.getEndpoint();
+ S3Client client = CLIENTS.get(endpoint);
if (client == null) {
final Configuration config = Configuration.getInstance();
- final String endpointStr = config.getString(Key.S3SOURCE_ENDPOINT);
+ if (endpoint == null) {
+ endpoint = config.getString(Key.S3SOURCE_ENDPOINT);
+ }
+ // Convert the endpoint string into a URI which is required by the
+ // client builder.
URI endpointURI = null;
- if (endpointStr != null) {
+ if (endpoint != null) {
try {
- endpointURI = new URI(endpointStr);
+ endpointURI = new URI(endpoint);
} catch (URISyntaxException e) {
LOGGER.error("Invalid URI for {}: {}",
Key.S3SOURCE_ENDPOINT, e.getMessage());
}
}
+ String region = info.getRegion();
+ if (region == null) {
+ region = config.getString(Key.S3SOURCE_REGION);
+ }
+ String accessKeyID = info.getAccessKeyID();
+ if (accessKeyID == null) {
+ accessKeyID = config.getString(Key.S3SOURCE_ACCESS_KEY_ID);
+ }
+ String secretAccessKey = info.getSecretAccessKey();
+ if (secretAccessKey == null) {
+ secretAccessKey = config.getString(Key.S3SOURCE_SECRET_KEY);
+ }
client = new S3ClientBuilder()
- .accessKeyID(config.getString(Key.S3SOURCE_ACCESS_KEY_ID))
- .secretAccessKey(config.getString(Key.S3SOURCE_SECRET_KEY))
+ .accessKeyID(accessKeyID)
+ .secretAccessKey(secretAccessKey)
.endpointURI(endpointURI)
- .region(config.getString(Key.S3SOURCE_REGION))
+ .region(region)
.build();
+ CLIENTS.put(endpoint, client);
}
return client;
}
@@ -248,7 +273,7 @@ static InputStream newObjectInputStream(S3ObjectInfo info)
*/
static InputStream newObjectInputStream(S3ObjectInfo info,
Range range) throws IOException {
- final S3Client client = getClientInstance();
+ final S3Client client = getClientInstance(info);
try {
GetObjectRequest request;
if (range != null) {
@@ -275,8 +300,11 @@ static InputStream newObjectInputStream(S3ObjectInfo info,
}
@Override
- public void checkAccess() throws IOException {
- getObjectAttributes();
+ public StatResult stat() throws IOException {
+ S3ObjectAttributes attrs = getObjectAttributes();
+ StatResult result = new StatResult();
+ result.setLastModified(attrs.lastModified);
+ return result;
}
@Override
@@ -290,14 +318,15 @@ private S3ObjectAttributes getObjectAttributes() throws IOException {
final S3ObjectInfo info = getObjectInfo();
final String bucket = info.getBucketName();
final String key = info.getKey();
- final S3Client client = getClientInstance();
+ final S3Client client = getClientInstance(info);
try {
HeadObjectResponse response = client.headObject(HeadObjectRequest.builder()
.bucket(bucket)
.key(key)
.build());
- objectAttributes = new S3ObjectAttributes();
- objectAttributes.length = response.contentLength();
+ objectAttributes = new S3ObjectAttributes();
+ objectAttributes.length = response.contentLength();
+ objectAttributes.lastModified = response.lastModified();
} catch (NoSuchBucketException | NoSuchKeyException e) {
throw new NoSuchFileException(info.toString());
} catch (S3Exception e) {
@@ -323,6 +352,7 @@ private S3ObjectAttributes getObjectAttributes() throws IOException {
*/
S3ObjectInfo getObjectInfo() throws IOException {
if (objectInfo == null) {
+ //noinspection SwitchStatementWithTooFewBranches
switch (LookupStrategy.from(Key.S3SOURCE_LOOKUP_STRATEGY)) {
case DELEGATE_SCRIPT:
try {
@@ -349,7 +379,10 @@ private S3ObjectInfo getObjectInfoUsingBasicStrategy() {
final String keyPrefix = config.getString(Key.S3SOURCE_PATH_PREFIX, "");
final String keySuffix = config.getString(Key.S3SOURCE_PATH_SUFFIX, "");
final String key = keyPrefix + identifier.toString() + keySuffix;
- return new S3ObjectInfo(key, bucketName);
+ S3ObjectInfo info = new S3ObjectInfo();
+ info.setBucketName(bucketName);
+ info.setKey(key);
+ return info;
}
/**
@@ -372,12 +405,18 @@ private S3ObjectInfo getObjectInfoUsingDelegateStrategy()
}
if (result.containsKey("bucket") && result.containsKey("key")) {
- String bucketName = result.get("bucket");
- String objectKey = result.get("key");
- return new S3ObjectInfo(objectKey, bucketName);
+ final S3ObjectInfo info = new S3ObjectInfo();
+ info.setBucketName(result.get("bucket"));
+ info.setKey(result.get("key"));
+ // These may be null.
+ info.setRegion(result.get("region"));
+ info.setEndpoint(result.get("endpoint"));
+ info.setAccessKeyID(result.get("access_key_id"));
+ info.setSecretAccessKey(result.get("secret_access_key"));
+ return info;
} else {
throw new IllegalArgumentException(
- "Returned hash does not include bucket and key");
+ "Returned hash must include bucket and key");
}
}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/Source.java b/src/main/java/edu/illinois/library/cantaloupe/source/Source.java
index bc21d30f9..f3901a31e 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/source/Source.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/Source.java
@@ -20,7 +20,7 @@
*
*
{@link #setIdentifier(Identifier)} and
* {@link #setDelegateProxy(DelegateProxy)} (in either order)
Checks the accessibility of the source image and returns some limited
+ * metadata.
*
*
Will be called only once.
*
+ * @return Instance with as many of its properties set as possible.
* @throws NoSuchFileException if an image corresponding to the set
* identifier does not exist.
* @throws AccessDeniedException if an image corresponding to the set
* identifier is not readable.
* @throws IOException if there is some other issue accessing the image.
*/
- void checkAccess() throws IOException;
+ StatResult stat() throws IOException;
/**
* N.B.: This default implementation throws an {@link
@@ -104,10 +106,11 @@ default Path getFile() throws IOException {
default void shutdown() {}
/**
- * N.B. 1: This method's return value affects the behavior of {@link
- * #getFile()}. See the documentation of that method for more information.
+ *
N.B. 1: This method's return value affects the behavior of {@link
+ * #getFile()}. See the documentation of that method for more
+ * information.
N.B. 2: The default implementation returns {@code false}.
*
* @return Whether the source image can be accessed via a {@link
* java.nio.file.Path}.
diff --git a/src/main/java/edu/illinois/library/cantaloupe/source/StatResult.java b/src/main/java/edu/illinois/library/cantaloupe/source/StatResult.java
new file mode 100644
index 000000000..f9f5710da
--- /dev/null
+++ b/src/main/java/edu/illinois/library/cantaloupe/source/StatResult.java
@@ -0,0 +1,20 @@
+package edu.illinois.library.cantaloupe.source;
+
+import java.time.Instant;
+
+/**
+ * Holds some limited metadata about a source image.
+ */
+public final class StatResult {
+
+ private Instant lastModified;
+
+ public Instant getLastModified() {
+ return lastModified;
+ }
+
+ void setLastModified(Instant lastModified) {
+ this.lastModified = lastModified;
+ }
+
+}
diff --git a/src/main/java/edu/illinois/library/cantaloupe/util/ArrayUtils.java b/src/main/java/edu/illinois/library/cantaloupe/util/ArrayUtils.java
index a751586bb..ce9b21600 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/util/ArrayUtils.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/util/ArrayUtils.java
@@ -17,7 +17,8 @@ public final class ArrayUtils {
* @return Chunked data.
*/
public static List chunkify(byte[] bytes, int maxChunkSize) {
- final List chunks = new ArrayList<>(10);
+ final int listSize = (int) Math.ceil(bytes.length / (double) maxChunkSize);
+ final List chunks = new ArrayList<>(listSize);
if (bytes.length <= maxChunkSize) {
chunks.add(bytes);
} else {
diff --git a/src/main/java/edu/illinois/library/cantaloupe/util/StringUtils.java b/src/main/java/edu/illinois/library/cantaloupe/util/StringUtils.java
index c8e4c38c5..015bf0d60 100644
--- a/src/main/java/edu/illinois/library/cantaloupe/util/StringUtils.java
+++ b/src/main/java/edu/illinois/library/cantaloupe/util/StringUtils.java
@@ -217,19 +217,6 @@ public static long toByteSize(String str) {
return Math.round(number * Math.pow(1024, exponent));
}
- /**
- * Strips any enclosing tags or other content around the {@literal rdf:RDF}
- * element within an RDF/XML XMP string.
- */
- public static String trimXMP(String xmp) {
- final int start = xmp.indexOf(" -1 && end > -1) {
- xmp = xmp.substring(start, end + 10);
- }
- return xmp;
- }
-
/**
* Returns an array of strings, one for each line in the string after it
* has been wrapped to fit lines of maxWidth. Lines end with any
diff --git a/src/main/resources/admin.vm b/src/main/resources/admin.vm
index 134f11b17..8890f1648 100644
--- a/src/main/resources/admin.vm
+++ b/src/main/resources/admin.vm
@@ -238,7 +238,9 @@
data-content="Location of the delegate script. This can be an
absolute path, or a filename; if only a filename is specified,
it will be searched for in the same folder as the config file,
- and then the current working directory.">?
+ and then the current working directory. The delegate script
+ pathname can also be set using the <code>-Dcantaloupe.delegate_script</code>
+ VM argument, which overrides this value.">?