Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,34 @@
package org.geowebcache.filter.request;

import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serial;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import javax.imageio.IIOException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import org.geowebcache.GeoWebCacheException;
import org.geowebcache.grid.GridSubset;
import org.geowebcache.layer.TileLayer;

public class FileRasterFilter extends RasterFilter {

@Serial
private static final long serialVersionUID = -6950985531575208956L;

/** Extensions of the raster formats a matrix can be stored in, all readable by the ImageIO readers. */
private static final Set<String> RASTER_EXTENSIONS = Set.of("png", "gif", "jpg", "jpeg", "tif", "tiff", "bmp");

private String storagePath;

private String fileExtension;
Expand Down Expand Up @@ -54,52 +69,87 @@ void setFileExtension(String fileExtension) {
@Override
protected BufferedImage loadMatrix(TileLayer layer, String gridSetId, int zoomLevel)
throws IOException, GeoWebCacheException {
File fh = new File(createFilePath(gridSetId, zoomLevel));
File fh = getMatrixPath(gridSetId, zoomLevel).toFile();

if (!fh.exists() || !fh.canRead()) {
throw new GeoWebCacheException(fh.getAbsolutePath() + " does not exist or is not readable");
}

BufferedImage img = ImageIO.read(fh);

int[] widthHeight = calculateWidthHeight(layer.getGridSubset(gridSetId), zoomLevel);

if (img.getWidth() != widthHeight[0] || img.getHeight() != widthHeight[1]) {
String msg = fh.getAbsolutePath()
+ " has dimensions "
+ img.getWidth()
+ ","
+ img.getHeight()
+ ", expected "
+ widthHeight[0]
+ ","
+ widthHeight[1];
throw new GeoWebCacheException(msg);
}

return img;
return readMatrix(fh, layer.getGridSubset(gridSetId), zoomLevel, fh.getAbsolutePath());
}

private String createFilePath(String gridSetId, int zoomLevel) {
String path =
storagePath + File.separator + this.getName() + "_" + gridSetId + "_" + zoomLevel + "." + fileExtension;
/** Decodes the matrix from a {@link File} or {@link InputStream}, checking format and size before decoding. */
private BufferedImage readMatrix(Object input, GridSubset subset, int zoomLevel, String source)
throws IOException, GeoWebCacheException {
int[] widthHeight = calculateWidthHeight(subset, zoomLevel);
ImageReader reader = getReader();
try (ImageInputStream iis = ImageIO.createImageInputStream(input)) {
// magic bytes check, some readers (e.g. TIFF) only warn on a foreign header
if (!reader.getOriginatingProvider().canDecodeInput(iis)) {
throw new GeoWebCacheException(source + " is not a " + fileExtension + " image");
}
reader.setInput(iis, true, true);
// header only, a huge image must fail before decoding it
int width = reader.getWidth(0);
int height = reader.getHeight(0);
if (width != widthHeight[0] || height != widthHeight[1]) {
throw new GeoWebCacheException(source + " has dimensions " + width + "," + height + ", expected "
+ widthHeight[0] + "," + widthHeight[1]);
}
return reader.read(0);
} finally {
reader.dispose();
}
}

return path;
/** Returns a new reader for the configured extension, the caller must dispose it. */
private ImageReader getReader() throws IOException {
// lower case, the JDK readers register lower case suffixes only
String extension = Objects.toString(fileExtension, "").toLowerCase(Locale.ENGLISH);
// allowlist, uploaded bytes must not reach whatever reader plugin is on the classpath
if (!RASTER_EXTENSIONS.contains(extension)) {
throw new IOException("Unsupported raster file extension: " + fileExtension);
}
return ImageIO.getImageReadersBySuffix(extension).next();
}

public void saveMatrix(byte[] data, TileLayer layer, String gridSetId, int zoomLevel) throws IOException {
// Persist
File fh = new File(createFilePath(gridSetId, zoomLevel));
try (FileOutputStream fos = new FileOutputStream(fh)) {
fos.write(data);
private Path getMatrixPath(String gridSetId, int zoomLevel) throws IOException {
Path dir = Path.of(storagePath).normalize();
String fileName = getName() + "_" + gridSetId + "_" + zoomLevel + "." + fileExtension;
Path file = dir.resolve(fileName).normalize();
if (!dir.equals(file.getParent())) {
throw new IOException("Invalid matrix file: " + fileName);
}
return file;
}

/**
* {@inheritDoc}
*
* @throws IllegalArgumentException if the grid set is not configured for the layer, z is out of its range, or the
* data is not an image of the configured format and expected size
*/
@Override
public void update(byte[] filterData, TileLayer layer, String gridSetId, int z) throws GeoWebCacheException {
GridSubset subset = layer.getGridSubset(gridSetId);
if (subset == null) {
throw new IllegalArgumentException(
"Unknown grid set " + gridSetId + " for layer " + layer.getName() + ": Typo?");
}
// the matrices are cached only up to zoomStop
if (z > getZoomStop() || subset.getCoverage(z) == null) {
throw new IllegalArgumentException("Zoom level " + z + " is out of range for grid set " + gridSetId);
}
try {
saveMatrix(filterData, layer, gridSetId, z);

// validate before writing, a bad upload must not replace a readable matrix
readMatrix(new ByteArrayInputStream(filterData), subset, z, "Uploaded matrix");
writeMatrix(getMatrixPath(gridSetId, z), filterData);

} catch (GeoWebCacheException e) {
throw new IllegalArgumentException(e.getMessage(), e);
} catch (IIOException | EOFException e) {
// decoding failure or data too short, a plain IOException is a storage or configuration problem instead
throw new IllegalArgumentException("Uploaded matrix is not a readable " + fileExtension + " image", e);
} catch (IOException e) {
throw new GeoWebCacheException(
this.getName() + " encountered an error while persisting matrix, " + e.getMessage());
Expand All @@ -113,6 +163,18 @@ public void update(byte[] filterData, TileLayer layer, String gridSetId, int z)
}
}

/** Replaces the matrix file atomically, a concurrent load never sees it half written. */
private void writeMatrix(Path target, byte[] data) throws IOException {
// same folder, so the move is a rename; the .tmp suffix never matches a matrix file name
Path tmp = Files.createTempFile(Path.of(storagePath), null, ".tmp");
try {
Files.write(tmp, data);
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
Files.deleteIfExists(tmp);
}
}

@Override
public void update(TileLayer layer, String gridSetId, int zoomStart, int zoomStop) throws GeoWebCacheException {
throw new GeoWebCacheException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
* Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* <p>This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* <p>You should have received a copy of the GNU Lesser General Public License along with this program. If not, see
* <http://www.gnu.org/licenses/>.
*
* <p>Copyright 2026
*/
package org.geowebcache.filter.request;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.arrayContaining;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.emptyArray;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import javax.imageio.ImageIO;
import org.geowebcache.GeoWebCacheException;
import org.geowebcache.grid.GridSubset;
import org.geowebcache.layer.TileLayer;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.function.ThrowingRunnable;
import org.junit.rules.TemporaryFolder;

public class FileRasterFilterTest {

// no colon, Windows paths reject it
private static final String GRID_SET = "grid";

@Rule
public TemporaryFolder temp = new TemporaryFolder();

private TileLayer layer;

@Before
public void setup() {
// a 2x2 tiles coverage at level 0
GridSubset subset = mock(GridSubset.class);
when(subset.getCoverage(0)).thenReturn(new long[] {0, 0, 1, 1, 0});
layer = mock(TileLayer.class);
when(layer.getGridSubset(GRID_SET)).thenReturn(subset);
}

@Test
public void testUnsupportedExtension() {
assertUpdateFails(newFilter("shp"), new byte[] {0}, GRID_SET, 0, "Unsupported raster file extension: shp");
}

@Test
public void testMissingExtension() {
assertUpdateFails(newFilter(null), new byte[] {0}, GRID_SET, 0, "Unsupported raster file extension: null");
}

@Test
public void testUnknownGridSet() throws Exception {
// typo, the layer only has "grid"
assertRejected(newFilter("png"), png(2, 2), "gird", 0, "Unknown grid set gird");
}

@Test
public void testZoomOutOfRange() throws Exception {
assertRejected(newFilter("png"), png(2, 2), GRID_SET, 3, "Zoom level 3 is out of range");
}

@Test
public void testNotARaster() {
assertRejected(newFilter("png"), "<html></html>".getBytes(UTF_8), GRID_SET, 0, "is not a png image");
}

@Test
public void testFormatMismatch() throws Exception {
assertRejected(newFilter("tiff"), png(2, 2), GRID_SET, 0, "is not a tiff image");
}

@Test
public void testWrongSize() throws Exception {
assertRejected(newFilter("png"), png(3, 3), GRID_SET, 0, "has dimensions 3,3, expected 2,2");
}

@Test
public void testTruncatedImage() throws Exception {
// valid PNG signature, header cut short
byte[] truncated = Arrays.copyOf(png(2, 2), 20);
assertRejected(newFilter("png"), truncated, GRID_SET, 0, "is not a readable png image");
}

@Test
public void testInvalidUploadKeepsMatrix() throws Exception {
FileRasterFilter filter = newFilter("png");
byte[] valid = png(2, 2);
filter.update(valid, layer, GRID_SET, 0);

assertThrows(IllegalArgumentException.class, () -> filter.update(png(3, 3), layer, GRID_SET, 0));
Path matrix = temp.getRoot().toPath().resolve("test_grid_0.png");
assertArrayEquals(valid, Files.readAllBytes(matrix));
// no temp file left behind
assertThat(temp.getRoot().list(), arrayContaining("test_grid_0.png"));
assertEquals(2, filter.matrices.get(GRID_SET)[0].getWidth());
}

/** Checks the update rejects the request as a client error, see {@link #assertFails}. */
private void assertRejected(FileRasterFilter filter, byte[] data, String gridSetId, int z, String message) {
assertFails(IllegalArgumentException.class, () -> filter.update(data, layer, gridSetId, z), message);
}

/** Checks the update fails as a server error, see {@link #assertFails}. */
private void assertUpdateFails(FileRasterFilter filter, byte[] data, String gridSetId, int z, String message) {
assertFails(GeoWebCacheException.class, () -> filter.update(data, layer, gridSetId, z), message);
}

/** Checks the update fails with the given message and leaves the storage folder empty. */
private void assertFails(Class<? extends Exception> type, ThrowingRunnable update, String message) {
Exception e = assertThrows(type, update);
assertThat(e.getMessage(), containsString(message));
assertThat(temp.getRoot().list(), emptyArray());
}

private static byte[] png(int width, int height) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY), "png", out);
return out.toByteArray();
}

private FileRasterFilter newFilter(String extension) {
FileRasterFilter filter = new FileRasterFilter();
filter.setName("test");
filter.setStoragePath(temp.getRoot().getPath());
filter.setFileExtension(extension);
filter.setZoomStop(2);
return filter;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.geotools.util.logging.Logging;
Expand Down Expand Up @@ -48,13 +50,16 @@ public void runUpdate(RequestFilter filter, TileLayer tl) throws RestException {
throw new RestException("Zip file cannot contain directories.", HttpStatus.BAD_REQUEST);
}

String[] parsedName = parseName(ze.getName());
String[] parsedName = parseName(ze.getName(), filter.getName());

byte[] data = ServletUtils.readStream(zis, 16 * 1024, 1500, false);

try {
filter.update(data, tl, parsedName[0], Integer.parseInt(parsedName[1]));

} catch (IllegalArgumentException e) {
throw new RestException(
"Error updating " + filter.getName() + ": " + e.getMessage(), HttpStatus.BAD_REQUEST);
} catch (GeoWebCacheException e) {
throw new RestException(
"Error updating " + filter.getName() + ": " + e.getMessage(),
Expand All @@ -75,15 +80,16 @@ public void runUpdate(RequestFilter filter, TileLayer tl) throws RestException {
}
}

String[] parseName(String fileName) throws RestException {
String[] strs = fileName.split("_");

// Slice away the extension, we dont have the data to test it
String[] zExt = strs[2].split("\\.");
strs[2] = zExt[0];

String[] gridSetIdZ = {strs[1], strs[2]};

return gridSetIdZ;
/** Splits a {@code <filterName>_<gridSetId>_<z>.<extension>} entry name into grid set id and zoom level. */
String[] parseName(String fileName, String filterName) throws RestException {
// greedy grid set group, ids may contain '_'; up to 9 digits always fit an int
Matcher m = Pattern.compile(Pattern.quote(filterName) + "_(.+)_(\\d{1,9})(\\.\\w+)?")
.matcher(fileName);
if (!m.matches()) {
throw new RestException(
"Invalid file name " + fileName + ", expected " + filterName + "_<gridSetId>_<z>.<extension>",
HttpStatus.BAD_REQUEST);
}
return new String[] {m.group(1), m.group(2)};
}
}
Loading
Loading