/* (c) 2017 Open Source Geospatial Foundation - all rights reserved
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.rest.resources;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.annotations.XStreamAlias;
import freemarker.template.ObjectWrapper;
import org.geoserver.AtomLink;
import org.geoserver.config.util.XStreamPersister;
import org.geoserver.ows.URLMangler;
import org.geoserver.ows.util.ResponseUtils;
import org.geoserver.platform.resource.Resource;
import org.geoserver.platform.resource.Resource.Type;
import org.geoserver.platform.resource.ResourceStore;
import org.geoserver.platform.resource.ResourceStoreFactory;
import org.geoserver.rest.ObjectToMapWrapper;
import org.geoserver.rest.RequestInfo;
import org.geoserver.rest.ResourceNotFoundException;
import org.geoserver.rest.RestBaseController;
import org.geoserver.rest.RestException;
import org.geoserver.rest.converters.XStreamJSONMessageConverter;
import org.geoserver.rest.converters.XStreamMessageConverter;
import org.geoserver.rest.converters.XStreamXMLMessageConverter;
import org.geoserver.rest.util.RESTUtils;
import org.geoserver.util.IOUtils;
import org.geotools.util.logging.Logging;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import static org.geoserver.rest.RestBaseController.ROOT_PATH;
@RestController
@RequestMapping(path = {ROOT_PATH + "/resource", ROOT_PATH + "/resource/**"})
public class ResourceController extends RestBaseController {
private ResourceStore resources;
static Logger LOGGER = Logging.getLogger("org.geoserver.catalog.rest");
private final DateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S z");
//TODO: Should we actually be doing this?
private final DateFormat FORMAT_HEADER = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH);
{
FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
FORMAT_HEADER.setTimeZone(TimeZone.getTimeZone("GMT"));
}
@Autowired
public ResourceController(@Qualifier("resourceStore") ResourceStoreFactory factory) throws Exception {
super();
this.resources = factory.getObject();
}
public ResourceController(ResourceStore store) {
super();
this.resources = store;
}
@Override
protected String getTemplateName(Object object) {
if(object instanceof ResourceDirectoryInfo) {
return "resourceDirectoryInfo.ftl";
} else if(object instanceof ResourceMetadataInfo) {
return "resourceMetadataInfo.ftl";
} else {
return super.getTemplateName(object);
}
}
/**
* Extract expected media type from supplied resource
* @param resource
* @param request
* @return Content type requested
*/
protected static MediaType getMediaType(Resource resource, HttpServletRequest request) {
if (resource.getType() == Resource.Type.DIRECTORY) {
return getFormat(request);
} else if (resource.getType() == Resource.Type.RESOURCE) {
String mimeType = URLConnection.guessContentTypeFromName(resource.name());
if (mimeType == null || MediaType.APPLICATION_OCTET_STREAM.toString().equals(mimeType)) {
//try guessing from data
try (InputStream is = new BufferedInputStream(resource.in())) {
mimeType = URLConnection.guessContentTypeFromStream(is);
} catch (IOException e) {
//do nothing, we'll just use application/octet-stream
}
}
return mimeType == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.valueOf(mimeType);
} else {
return null;
}
}
/**
* Access resource requested, note this may be UNDEFINED
*
* @param request
* @return Resource requested, may be UNDEFINED if not found.
*/
protected Resource resource(HttpServletRequest request) {
String path = request.getPathInfo();
//Strip off "/resource"
path = path.substring(9);
//handle special characters
try {
path = URLDecoder.decode(path, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RestException("Could not decode the resource URL to UTF-8 format", HttpStatus.INTERNAL_SERVER_ERROR);
}
return resources.get(path);
}
/**
* Look up operation query string value, defaults to {@link Operation#DEFAULT} if not provided.
* @param request
* @return operation defined by query string, or {@link Operation#DEFAULT} if not provided
*/
protected static Operation operation(HttpServletRequest request) {
String operation = RESTUtils.getQueryStringValue(request, "operation");
if (operation != null) {
operation = operation.trim().toUpperCase();
try {
return Operation.valueOf(operation);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("Unknown operation '"+operation+"' requested");
}
}
else {
return Operation.DEFAULT;
}
}
protected static MediaType getFormat(HttpServletRequest request) {
String format = RESTUtils.getQueryStringValue(request, "format");
if ("xml".equals(format)) {
return MediaType.APPLICATION_XML;
} else if ("json".equals(format)) {
return MediaType.APPLICATION_JSON;
} else {
return MediaType.TEXT_HTML;
}
}
protected static String href(String path) {
return ResponseUtils.buildURL(RequestInfo.get().servletURI("resource/"),
ResponseUtils.urlEncode(path, '/'), null, URLMangler.URLType.RESOURCE);
}
protected static String formatHtmlLink(String link) {
return link.replaceAll("&", "&");
}
/**
* Actual get implementation handles a distrubing number of cases.
* <p>
* All the inner Resource classes are data transfer object for representing resource metadata, this method also can return direct access to
* resource contents.
* <p>
* Headers:
* <ul>
* <li>Location: Link to resource
* <li>Resource-Type: DIRECTORY, RESOURCE, UNDEFINED
* <li>Resource-Parent: Link to parent DIRECTORY
* <li>Last-Modified: Last modifed date (this is a standard header).
* </ul>
*
* @param request Request indicating resource, parameters indicating {@link ResourceController.Operation} and {@link MediaType}.
* @param response Response provided allowing us to set headers (content type, content length, Resource-Parent, Resource-Type).
* @return Returns wrapped info object, or direct access to resource contents depending on requested operation
*/
@RequestMapping(method = {RequestMethod.GET, RequestMethod.HEAD}, produces = {MediaType.ALL_VALUE})
public Object resourceGet(HttpServletRequest request, HttpServletResponse response) {
Resource resource = resource(request);
Operation operation = operation(request);
Object result;
response.setContentType(getFormat(request).toString());
if (operation == Operation.METADATA) {
result = wrapObject(new ResourceMetadataInfo(resource, request), ResourceMetadataInfo.class);
} else {
if (resource.getType() == Resource.Type.UNDEFINED) {
throw new ResourceNotFoundException("Undefined resource path.");
} else {
HttpHeaders responseHeaders = new HttpHeaders();
MediaType mediaType = getMediaType(resource, request);
responseHeaders.setContentType(mediaType);
response.setContentType(mediaType.toString());
if (request.getMethod().equals("HEAD")) {
result = new ResponseEntity("", responseHeaders, HttpStatus.OK);
} else if (resource.getType() == Resource.Type.DIRECTORY) {
result = wrapObject(new ResourceDirectoryInfo(resource, request), ResourceDirectoryInfo.class);
} else {
result = new ResponseEntity(resource.in(), responseHeaders, HttpStatus.OK);
}
response.setHeader("Location", href(resource.path()));
response.setHeader("Last-Modified", FORMAT_HEADER.format(resource.lastmodified()));
if (!"".equals(resource.path())) {
response.setHeader("Resource-Parent", href(resource.parent().path()));
}
response.setHeader("Resource-Type", resource.getType().toString().toLowerCase());
}
}
return result;
}
/**
* Upload or modify resource contents:
* <ul>
* <li>{@link Operation#DEFAULT}: update resource contents (creating if needed).</li>
* <li>{@link Operation#MOVE}: moves a resource, indicated by request body, to this location.</li>
* <li>{@link Operation#COPY}: duplicates a resource, indicated by request body, to this location</li>
* </ul>
* @paarm request
* @param response {@link HttpStatus#CREATED} for a new resource, or {@link HttpStatus#OK} when updating existing resource
*/
@PutMapping(consumes = {MediaType.ALL_VALUE})
@ResponseStatus(HttpStatus.CREATED)
public void resourcePut(HttpServletRequest request,HttpServletResponse response){
Resource resource = resource(request);
if (resource.getType() == Type.DIRECTORY) {
throw new RestException("Attempting to write data to a directory.", HttpStatus.METHOD_NOT_ALLOWED );
}
Operation operation = operation(request);
if (operation == Operation.METADATA ){
throw new RestException("Attempting to write data to metadata.", HttpStatus.METHOD_NOT_ALLOWED );
}
boolean isNew = resource.getType() == Type.UNDEFINED;
if (operation == Operation.COPY || operation == Operation.MOVE) {
String path;
try {
path = IOUtils.toString( request.getInputStream());
} catch (IOException e) {
throw new RestException("Unable to read content:" + e.getMessage(),
HttpStatus.INTERNAL_SERVER_ERROR, e);
}
Resource source = resources.get(path);
if( source.getType() == Type.UNDEFINED){
throw new RestException("Unable to locate '" + path + "'.", HttpStatus.NOT_FOUND);
}
if ( operation == Operation.MOVE){
boolean moved = source.renameTo(resource);
if(!moved){
throw new RestException("Rename operation failed.", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
else { // COPY
if( source.getType() == Type.DIRECTORY){
throw new RestException("Cannot copy directory.", HttpStatus.METHOD_NOT_ALLOWED);
}
try {
IOUtils.copy( source.in(), resource.out());
} catch (IOException e) {
throw new RestException("Copy operation failed:"+e, HttpStatus.INTERNAL_SERVER_ERROR,e);
}
}
}
else if (operation == Operation.DEFAULT){
try {
IOUtils.copy( request.getInputStream(), resource.out());
if (LOGGER.isLoggable(Level.INFO)) {
LOGGER.fine("PUT resource: " + resource.path());
}
} catch (IOException e) {
throw new RestException(
"Unable to read content to '" + resource.path() + "':" + e.getMessage(),
HttpStatus.INTERNAL_SERVER_ERROR, e);
}
}
else {
throw new IllegalStateException("Unexpected operation '"+operation+"'");
}
// fill in correct status / header details
if(isNew){
response.setStatus(HttpStatus.CREATED.value());
}
}
/**
* Delete resource
*/
@DeleteMapping
public void resourceDelete(HttpServletRequest request) {
Resource resource = resource(request);
if (Type.UNDEFINED.equals(resource.getType())) {
throw new ResourceNotFoundException("Resource '" + resource.path() + "' not found");
}
boolean removed = resource.delete();
if (!removed) {
throw new RestException("Resource '" + resource.path() + "' not removed", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Verifies mime type and use {@link RESTUtils}
* @param directory
* @param filename
* @param request
* @return
*/
protected Resource fileUpload(Resource directory, String filename, HttpServletRequest request) {
if (LOGGER.isLoggable(Level.INFO)) {
LOGGER.info("PUT file: mimetype=" + request.getContentType() + ", path="
+ directory.path());
}
try {
return RESTUtils.handleBinUpload(filename, directory, false, request);
} catch (IOException problem) {
throw new RestException(problem.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR,
problem);
}
}
//@Override
//protected <T> ObjectWrapper createObjectWrapper(Class<T> clazz) {
// return new ResourceToMapWrapper<>(clazz);
//}
@Override
public void configurePersister(XStreamPersister persister, XStreamMessageConverter converter) {
XStream xstream = persister.getXStream();
xstream.alias("child", ResourceChildInfo.class);
xstream.alias("ResourceDirectory", ResourceDirectoryInfo.class);
xstream.alias("ResourceMetadata", ResourceMetadataInfo.class);
if (converter instanceof XStreamXMLMessageConverter) {
AtomLink.configureXML(xstream);
xstream.aliasField("atom:link", ResourceParentInfo.class, "link");
xstream.aliasField("atom:link", ResourceChildInfo.class, "link");
} else if (converter instanceof XStreamJSONMessageConverter) {
AtomLink.configureJSON(xstream);
}
}
@Override
protected <T> ObjectWrapper createObjectWrapper(Class<T> clazz) {
return new ObjectToMapWrapper<>(clazz, Arrays.asList(AtomLink.class,
ResourceDirectoryInfo.class, ResourceMetadataInfo.class, ResourceParentInfo.class, ResourceChildInfo.class));
}
/**
* Operation requested from the REST endpoint.
*/
public enum Operation {
/** Depends on context (different functionality for directory, resource, undefined) */
DEFAULT,
/** Requests metadata summary of resource */
METADATA,
/** Moves resource to new location */
MOVE,
/** Duplicate resource to a new location */
COPY
}
/**
* Used for parent reference (to indicate directory containing resource).
*
* XML/Json object for resource reference.
*/
protected static class ResourceParentInfo {
private String path;
private AtomLink link;
public ResourceParentInfo(String path, AtomLink link) {
this.path = path;
this.link = link;
}
public String getPath() {
return path;
}
public AtomLink getLink() {
return link;
}
}
/**
* Lists Resource for html, json, xml output, as the contents of {@link ResourceDirectoryInfo}.
*/
@XStreamAlias("child")
protected static class ResourceChildInfo {
private String name;
private AtomLink link;
public ResourceChildInfo(String name, AtomLink link) {
this.name = name;
this.link = link;
}
public String getName() {
return name;
}
public AtomLink getLink() {
return link;
}
}
/**
* Resource metadata for individual resource entry (name, last modified, type, etc...).
*/
@XStreamAlias("ResourceMetadata")
protected static class ResourceMetadataInfo {
private String name;
private ResourceParentInfo parent;
private Date lastModified;
private String type;
public ResourceMetadataInfo(String name, ResourceParentInfo parent,
Date lastModified, String type) {
this.name = name;
this.parent = parent;
this.lastModified = lastModified;
this.type = type;
}
/**
* Create from resource.
* The class must be static for serialization, but output is request dependent so passing on self.
*/
protected ResourceMetadataInfo(Resource resource, HttpServletRequest request, boolean isDir) {
if (!resource.path().isEmpty()) {
parent = new ResourceParentInfo("/" + resource.parent().path(),
new AtomLink(href(resource.parent().path()), "alternate",
getFormat(request).toString()));
}
lastModified = new Date(resource.lastmodified());
type = isDir ? null : resource.getType().toString().toLowerCase();
name = resource.name();
}
public ResourceMetadataInfo(Resource resource, HttpServletRequest request) {
this(resource, request, false);
}
public ResourceParentInfo getParent() {
return parent;
}
public Date getLastModified() {
return lastModified;
}
public String getType() {
return type;
}
public String getName() {
return name;
}
}
/**
* Extends ResourceMetadataInfo to list contents.
*
* @author Niels Charlier
*/
@XStreamAlias("ResourceDirectory")
protected static class ResourceDirectoryInfo extends ResourceMetadataInfo {
private List<ResourceChildInfo> children = new ArrayList<>();
public ResourceDirectoryInfo(String name, ResourceParentInfo parent, Date lastModified,
String type) {
super(name, parent, lastModified, type);
}
/**
* Create from resource.
* The class must be static for serialization, but output is request dependent so passing on self.
*/
public ResourceDirectoryInfo(Resource resource, HttpServletRequest request) {
super(resource, request, true);
for (Resource child : resource.list()) {
children.add(new ResourceChildInfo(child.name(),
new AtomLink(href(child.path()), "alternate",
getMediaType(child, request).toString())));
}
}
public List<ResourceChildInfo> getChildren() {
return children;
}
}
}