/******************************************************************************* * Australian National University Data Commons * Copyright (C) 2013 The Australian National University * * This file is part of Australian National University Data Commons. * * Australian National University Data Commons is free software: you * can redistribute it and/or modify it under the terms of the GNU * General Public License as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later * version. * * 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. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package au.edu.anu.datacommons.storage; import static java.text.MessageFormat.format; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.ResponseBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Component; import au.edu.anu.datacommons.data.db.model.FedoraObject; import au.edu.anu.datacommons.storage.info.FileInfo; import au.edu.anu.datacommons.storage.info.RecordDataSummary; import au.edu.anu.datacommons.storage.provider.StorageException; import com.sun.jersey.api.NotFoundException; import com.sun.jersey.api.view.Viewable; /** * Provides REST endpoints to which rest requests related to data storage of a collection record are sent. * * @author Rahul Khanna * */ @Path("records/{pid:[a-z]*(:|%3[aA])[0-9]*}") @Component @Scope("request") public class StorageResource extends AbstractStorageResource { private static final Logger LOGGER = LoggerFactory.getLogger(StorageResource.class); @GET public Response get() { return Response.ok("Test").build(); } /** * GET request for a file or a folder. For a file, the response is an octet-stream with the contents of the file. * For a folder the response is an HTML page with the list of files in the folder. * * @param pid * Identifier of collection record * @param path * Path to the file/folder. Note that 'data/' is not included in the path. * @return HTTP response */ @GET @Path("data/{path:.*}") @Produces({ "text/html; qs=1.1", MediaType.WILDCARD }) public Response getFileOrDirAsHtml(@PathParam("pid") String pid, @PathParam("path") String path) { return createFileOrDirResponse(pid, path, "/storage.jsp"); } /** * GET request for information about a file or folder. The response is an XML or JSON representation of the * RecordDataInfo object. * * @param pid * Identifier of collection record * @param path * Filepath of the file/folder whose information is requested. * @return HTTP Response */ @GET @Path("data/{path:.*}") @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML }) public Response getFileOrDirAsJsonXml(@PathParam("pid") String pid, @PathParam("path") String path) { return createFileOrDirResponse(pid, path, null); } /** * HTTP request for accepting a file upload requests. Upload requests from the Python script, JUpload applet and * HTML5 drag and drop uploaders are handled by this endpoint. * * @param pid * Identifier of collection record * @param path * Filepath where the uploaded file will be stored. Note that 'data/' is not part of the path. * @param src * Optional query parameter specifying the source of the upload. Requests with parameter src=jupload are * handled differently * @param is * Contents of the file being uploaded. * @return HTTP Response */ @POST @Path("data/{path:.*}") @Consumes({ MediaType.APPLICATION_OCTET_STREAM, MediaType.MULTIPART_FORM_DATA }) @PreAuthorize("hasRole('ROLE_ANU_USER')") public Response postUploadFile(@PathParam("pid") String pid, @PathParam("path") String path, @QueryParam("src") String src, InputStream is) { Response resp = null; if (src == null || src.length() == 0) { List<String> userAgentHeader = httpHeaders.getRequestHeader("User-Agent"); if (userAgentHeader != null && !userAgentHeader.isEmpty()) { src = userAgentHeader.get(0); } } fedoraObjectService.getItemByPidWriteAccess(pid); if (src.equals("jupload")) { resp = processJUpload(pid, path); } else { resp = processRestUpload(pid, path, is); } return resp; } /** * Accepts POST requests for: * * <ul> * <li>Downloading a Zip file. This is done with a POST request because a GET request placed a limit on the number * of files that can be included in the ZIP file depending on the allowable query size configured in the web server. * That is, selecting 1000 files would result in a GET request 1000 query parameters. * <li>Add one or more external references * <li>Remove one or more external references * <li>Toggle public flag for files of a collection. * </ul> * * @param pid * Identifier of collection record * @param path * Filepath of file. Currently this parameter is not used, but may be used in the future when data values * are stored against individual files. * @param action * Action to perform. The following values are valid: * <ul> * <li>zip * <li>addExtRef * <li>delExtRef * <li>filesPublic * </ul> * @param items * parameters to be used in performing the 'action'. For zip file creation, list of filepaths, for * adding/deleting external references, list of URLs. * @return HTTP Response */ @POST @Path("data/{path:.*}") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @PreAuthorize("hasRole('ROLE_ANU_USER')") public Response postForm(@PathParam("pid") String pid, @PathParam("path") String path, @QueryParam("action") String action, @FormParam("i") Set<String> items) { Response resp = null; if (action != null) { if (action.equals("zip") && !items.isEmpty()) { resp = createZipFileResponse(pid, path, items); } else if (action.equals("addExtRef") && !items.isEmpty()) { resp = createAddExtRefResponse(pid, items); } else if (action.equals("delExtRef") && !items.isEmpty()) { resp = createDelExtRefResponse(pid, items); } else if (action.equals("filesPublic") && !items.isEmpty()) { resp = processSetFilesPublicFlag(pid, items.iterator().next()); } else if (action.equals("renameFile") && !items.isEmpty()) { resp = createRenameResponse(pid, path, items); } } return resp; } /** * Accepts a POST request to create a folder. This endpoint is reached when there is no body in the POST request. * * @param pid * Identifier of the collection request * @param path * Filepath of the folder to be created. * @return HTTP response */ @POST @Path("data/{path:.*}") @PreAuthorize("hasRole('ROLE_ANU_USER')") public Response createDir(@PathParam("pid") String pid, @PathParam("path") String path) { Response resp = null; LOGGER.info("User {} ({}) requested creation of directory {} in record {}", getCurUsername(), getRemoteIp(), uriInfo.getPath(true).toString(), pid); fedoraObjectService.getItemByPidWriteAccess(pid); path = appendSeparator(path); try { storageController.addDir(pid, path); resp = Response.created(getUri(pid, path)).build(); } catch (IOException | StorageException e) { LOGGER.error(e.getMessage(), e); resp = Response.serverError().entity(e.getMessage()).build(); } return resp; } /** * Deletes a file or folder within a collection record. * * @param pid * Identifier of collection record * @param path * Path to the file or directory to be deleted. * @return HTTP Response */ @DELETE @Path("data/{path:.*}") @PreAuthorize("hasRole('ROLE_ANU_USER')") public Response deleteFile(@PathParam("pid") String pid, @PathParam("path") String path) { fedoraObjectService.getItemByPidWriteAccess(pid); return processDeleteFile(pid, path); } /** * Provides an endpoint for accepting requests for performing administrative tasks on a collection record's files. * * @param pid * Identifier of collection record * @param task * Task to perform as String. The following values are valid: * <ul> * <li>verify * <li>complete * </ul> * @return HTTP Response */ @GET @Path("admin") @PreAuthorize("hasRole('ROLE_ADMIN')") public Response performAdminTask(@PathParam("pid") String pid, @QueryParam("task") String task) { Response resp = null; Map<String, Object> model = new HashMap<String, Object>(); try { if (!storageController.dirExists(pid, "")) { throw new NotFoundException(); } } catch (StorageException e1) { throw new NotFoundException(); } try { // if (task.equals("verify")) { // VerificationResults results = dcStorage.verifyBag(pid); // model.put("results", results); // resp = Response.ok(new Viewable("/verificationresults.jsp", model)).build(); // } else if (task.equals("complete")) { // dcStorage.recompleteBag(pid); // } // TODO Implement this. throw new UnsupportedOperationException(); } catch (Exception e) { LOGGER.error(e.getMessage(), e); resp = Response.serverError().entity(e.getMessage()).build(); } return resp; } /** * Creates an HTTP response depending on the item represented by the filepath in the specified record. If file, then * response is an octet stream. If directory, then response is an HTML or XML/JSON response depending on Accepts * header. * * @param pid * Identifier of collection record * @param path * Path to the file or folder being requested. * @param template * JSP template to use if an HTML response is required. null if XML/JSON response is required. * @return HTTP Response */ private Response createFileOrDirResponse(String pid, String path, String template) { Response resp = null; FedoraObject fo = fedoraObjectService.getItemByPid(pid); if (fo == null) { throw new NotFoundException(uriInfo.getAbsolutePath()); } if (!(isPublishedAndPublic(fo))) { fo = null; fo = fedoraObjectService.getItemByPidReadAccess(pid); } Map<String, Object> model = new HashMap<String, Object>(); try { if (path == null || path.length() == 0 || storageController.dirExists(pid, path)) { LOGGER.info("User {} ({}) requested list of files in {}/data/{}", getCurUsername(), getRemoteIp(), pid, path); RecordDataSummary rdi = storageController.getRecordDataSummary(pid); FileInfo fileInfo = null; if (storageController.dirExists(pid, "")) { fileInfo = storageController.getFileInfo(pid, path); } List<FileInfo> parents = new ArrayList<FileInfo>(); for (FileInfo parent = fileInfo; parent != null && parent.getParent() != null; parent = parent.getParent()) { parents.add(0, parent); } if (template != null) { model.put("fo", fo); model.put("rdi", rdi); model.put("fileInfo", fileInfo); model.put("parents", parents); model.put("path", path); model.put("isFilesPublic", fo.isFilesPublic().toString()); resp = Response.ok(new Viewable(template, model)).build(); } else { resp = Response.ok(rdi).build(); } } else if (storageController.fileExists(pid, path)) { LOGGER.info("User {} ({}) requested file {}/data/{}", getCurUsername(), getRemoteIp(), pid, path); resp = getBagFileOctetStreamResp(pid, path); } else { throw new NotFoundException(uriInfo.getAbsolutePath()); } } catch (IOException | StorageException e) { LOGGER.error(e.getMessage(), e); resp = Response.ok(e.getMessage()).build(); } return resp; } /** * Creates an HTTP response with an octetstream body comprising of a Zip stream of one or more files in a specified * collection record. * * @param pid * Identifier of collection record * @param path * Root path to which all specified filepaths will be appended * @param filepaths * Collection of relative paths (relative to path param) to be included in the Zip stream * @return HTTP Response */ private Response createZipFileResponse(String pid, String path, Set<String> filepaths) { ResponseBuilder resp; fedoraObjectService.getItemByPidReadAccess(pid); Set<String> pathPrependedFilepaths = new HashSet<String>(filepaths.size()); for (String filepath : filepaths) { pathPrependedFilepaths.add(path + filepath); } try { InputStream zipStream = storageController.createZipStream(pid, pathPrependedFilepaths); resp = Response.ok(zipStream, MediaType.APPLICATION_OCTET_STREAM_TYPE); String clientFilename = format("{0}.{1}", DcStorage.convertToDiskSafe(pid), "zip"); resp.header("Content-Disposition", format("attachment; filename=\"{0}\"", clientFilename)); } catch (IOException | StorageException e) { LOGGER.error(e.getMessage(), e); resp = Response.serverError().entity(e.getMessage()); } return resp.build(); } private Response createAddExtRefResponse(String pid, Set<String> items) { ResponseBuilder resp = null; try { storageController.addExtRefs(pid, items); resp = Response.ok(); } catch (IOException | StorageException e) { LOGGER.error(e.getMessage(), e); resp = Response.serverError().entity(e.getMessage()); } return resp.build(); } private Response createDelExtRefResponse(String pid, Set<String> items) { ResponseBuilder resp = null; try { storageController.deleteExtRefs(pid, items); resp = Response.ok(); } catch (IOException | StorageException e) { LOGGER.error(e.getMessage(), e); resp = Response.serverError().entity(e.getMessage()); } return resp.build(); } private Response createRenameResponse(String pid, String path, Set<String> items) { ResponseBuilder resp = null; try { storageController.renameFile(pid, path, items.iterator().next()); resp = Response.ok(); } catch (IOException | StorageException e) { LOGGER.error(e.getMessage(), e); resp = Response.serverError().entity(e.getMessage()); } return resp.build(); } private String removeTrailingSlash(String path) { if (path.length() > 0 && path.charAt(path.length() - 1) == '/') { return path.substring(0, path.length() - 1); } else { return path; } } }