001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.fcrepo.http.api;
019
020import io.micrometer.core.annotation.Timed;
021import org.apache.commons.io.IOUtils;
022import org.apache.jena.rdf.model.Model;
023import org.apache.jena.riot.RDFDataMgr;
024import org.apache.jena.shared.JenaException;
025
026import org.fcrepo.config.AuthPropsConfig;
027import org.fcrepo.http.commons.domain.PATCH;
028import org.fcrepo.http.commons.domain.RDFMediaType;
029import org.fcrepo.http.commons.responses.RdfNamespacedStream;
030import org.fcrepo.kernel.api.RdfStream;
031import org.fcrepo.kernel.api.exception.AccessDeniedException;
032import org.fcrepo.kernel.api.exception.ItemNotFoundException;
033import org.fcrepo.kernel.api.exception.PathNotFoundException;
034import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException;
035import org.fcrepo.kernel.api.identifiers.FedoraId;
036import org.fcrepo.kernel.api.models.FedoraResource;
037import org.fcrepo.kernel.api.models.WebacAcl;
038import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
039import org.fcrepo.kernel.api.services.WebacAclService;
040import org.slf4j.Logger;
041import org.springframework.context.annotation.Scope;
042
043import javax.inject.Inject;
044import javax.servlet.http.HttpServletResponse;
045import javax.ws.rs.BadRequestException;
046import javax.ws.rs.ClientErrorException;
047import javax.ws.rs.Consumes;
048import javax.ws.rs.DELETE;
049import javax.ws.rs.GET;
050import javax.ws.rs.HeaderParam;
051import javax.ws.rs.PUT;
052import javax.ws.rs.Path;
053import javax.ws.rs.PathParam;
054import javax.ws.rs.Produces;
055import javax.ws.rs.core.Context;
056import javax.ws.rs.core.MediaType;
057import javax.ws.rs.core.Request;
058import javax.ws.rs.core.Response;
059import javax.ws.rs.core.UriInfo;
060import java.io.IOException;
061import java.io.InputStream;
062import java.net.URI;
063import java.nio.file.Files;
064
065import static java.nio.charset.StandardCharsets.UTF_8;
066import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
067import static javax.ws.rs.core.MediaType.valueOf;
068import static javax.ws.rs.core.Response.Status.CONFLICT;
069import static javax.ws.rs.core.Response.created;
070import static javax.ws.rs.core.Response.noContent;
071import static javax.ws.rs.core.Response.ok;
072import static org.apache.commons.lang3.StringUtils.isBlank;
073import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel;
074import static org.apache.jena.rdf.model.ResourceFactory.createResource;
075import static org.apache.jena.riot.Lang.TTL;
076import static org.apache.jena.riot.WebContent.contentTypeSPARQLUpdate;
077import static org.fcrepo.http.commons.domain.RDFMediaType.JSON_LD;
078import static org.fcrepo.http.commons.domain.RDFMediaType.N3_ALT2_WITH_CHARSET;
079import static org.fcrepo.http.commons.domain.RDFMediaType.N3_WITH_CHARSET;
080import static org.fcrepo.http.commons.domain.RDFMediaType.NTRIPLES;
081import static org.fcrepo.http.commons.domain.RDFMediaType.RDF_XML;
082import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_HTML_WITH_CHARSET;
083import static org.fcrepo.http.commons.domain.RDFMediaType.TEXT_PLAIN_WITH_CHARSET;
084import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_WITH_CHARSET;
085import static org.fcrepo.http.commons.domain.RDFMediaType.TURTLE_X;
086import static org.slf4j.LoggerFactory.getLogger;
087
088/**
089 * @author lsitu
090 * @author peichman
091 * @since 4/20/18
092 */
093@Timed
094@Scope("request")
095@Path("/{path: (.+/)?}fcr:acl")
096public class FedoraAcl extends ContentExposingResource {
097
098    private static final Logger LOGGER = getLogger(FedoraAcl.class);
099
100    private static final String ROOT_AUTHORIZATION_LOCATION = "/root-authorization.ttl";
101
102    @Context protected Request request;
103    @Context protected HttpServletResponse servletResponse;
104    @Context protected UriInfo uriInfo;
105
106    @PathParam("path") protected String externalPath;
107
108    @Inject
109    private AuthPropsConfig authPropsConfig;
110
111    @Inject
112    private WebacAclService webacAclService;
113
114    /**
115     * Default JAX-RS entry point
116     */
117    public FedoraAcl() {
118        super();
119    }
120
121    /**
122     * PUT to create FedoraWebacACL resource.
123     * @param requestContentType The content type of the resource body
124     * @param requestBodyStream  The request body as stream
125     * @return the response for a request to create a Fedora WebAc acl
126     */
127    @PUT
128    public Response createFedoraWebacAcl(@HeaderParam(CONTENT_TYPE) final MediaType requestContentType,
129                                         final InputStream requestBodyStream) {
130
131        if (resource().isAcl() || resource().isMemento()) {
132            throw new BadRequestException("ACL resource creation is not allowed for resource " + resource().getId());
133        }
134        LOGGER.info("PUT acl resource '{}'", externalPath());
135
136        final FedoraId aclId = identifierConverter().pathToInternalId(externalPath()).asAcl();
137        final boolean exists = doesResourceExist(transaction(), aclId, false);
138
139        try {
140            final MediaType contentType =
141                    requestContentType == null ?
142                            RDFMediaType.TURTLE_TYPE : valueOf(getSimpleContentType(requestContentType));
143
144            if (isRdfContentType(contentType.toString())) {
145                final Model model = httpRdfService.bodyToInternalModel(aclId,
146                        requestBodyStream, contentType, identifierConverter(), hasLenientPreferHeader());
147                doInDbTxWithRetry(() -> {
148                    if (exists) {
149                        replacePropertiesService.perform(transaction(), getUserPrincipal(), aclId, model);
150                    } else {
151                        webacAclService.create(transaction(), aclId, getUserPrincipal(), model);
152                    }
153                    transaction().commitIfShortLived();
154                });
155            } else {
156                throw new BadRequestException("Content-Type (" + requestContentType + ") is invalid." +
157                        " Try text/turtle or other RDF compatible type.");
158            }
159
160            try {
161                final var aclResource = getFedoraResource(transaction(), aclId);
162                addCacheControlHeaders(servletResponse, aclResource, transaction());
163                final URI location = getUri(aclResource);
164                if (!exists) {
165                    return created(location).build();
166                } else {
167                    return noContent().location(location).build();
168                }
169            } catch (final PathNotFoundException e) {
170                throw new PathNotFoundRuntimeException(e.getMessage(), e);
171            }
172        } finally {
173            transaction().releaseResourceLocksIfShortLived();
174        }
175    }
176
177    /**
178     * PATCH to update an FedoraWebacACL resource using SPARQL-UPDATE
179     *
180     * @param requestBodyStream the request body stream
181     * @return 204
182     * @throws IOException if IO exception occurred
183     */
184    @PATCH
185    @Consumes({ contentTypeSPARQLUpdate })
186    public Response updateSparql(final InputStream requestBodyStream)
187        throws IOException, ItemNotFoundException {
188        hasRestrictedPath(externalPath);
189
190        if (null == requestBodyStream) {
191            throw new BadRequestException("SPARQL-UPDATE requests must have content!");
192        }
193
194        final FedoraId originalId = identifierConverter().pathToInternalId(externalPath());
195        final FedoraId aclId = originalId.asAcl();
196        final FedoraResource aclResource;
197        try {
198             aclResource = getFedoraResource(transaction(), aclId);
199        } catch (final PathNotFoundException exc) {
200            if (originalId.isRepositoryRoot()) {
201                throw new ClientErrorException("The default root ACL is system generated and cannot be modified. " +
202                        "To override the default root ACL you must PUT a user-defined ACL to this endpoint.",
203                        CONFLICT);
204            }
205            throw new ItemNotFoundException("not found");
206        }
207
208        try {
209            final String requestBody = IOUtils.toString(requestBodyStream, UTF_8);
210            if (isBlank(requestBody)) {
211                throw new BadRequestException("SPARQL-UPDATE requests must have content!");
212            }
213
214            evaluateRequestPreconditions(request, servletResponse, aclResource, transaction());
215
216            LOGGER.info("PATCH for '{}'", externalPath);
217            final String newRequest = httpRdfService.patchRequestToInternalString(aclResource.getFedoraId(),
218                    requestBody, identifierConverter());
219            LOGGER.debug("PATCH request translated to '{}'", newRequest);
220
221            doInDbTxWithRetry(() -> {
222                patchResourcewithSparql(aclResource, newRequest);
223                transaction().commitIfShortLived();
224            });
225
226            addCacheControlHeaders(servletResponse, aclResource, transaction());
227
228            return noContent().build();
229        } catch (final IllegalArgumentException iae) {
230            throw new BadRequestException(iae.getMessage());
231        } catch (final AccessDeniedException e) {
232            throw e;
233        } catch (final RuntimeException ex) {
234            final Throwable cause = ex.getCause();
235            if (cause instanceof PathNotFoundRuntimeException) {
236                // the sparql update referred to a repository resource that doesn't exist
237                throw new BadRequestException(cause.getMessage());
238            }
239            throw ex;
240        } finally {
241            transaction().releaseResourceLocksIfShortLived();
242        }
243    }
244
245    @Override
246    protected String externalPath() {
247        return externalPath;
248    }
249
250    /**
251     * GET to retrieve the ACL resource.
252     *
253     * @return a binary or the triples for the specified node
254     * @throws IOException if IO exception occurred
255     */
256    @GET
257    @Produces({ TURTLE_WITH_CHARSET + ";qs=1.0", JSON_LD + ";qs=0.8",
258                N3_WITH_CHARSET, N3_ALT2_WITH_CHARSET, RDF_XML, NTRIPLES, TEXT_PLAIN_WITH_CHARSET,
259                TURTLE_X, TEXT_HTML_WITH_CHARSET })
260    public Response getResource()
261            throws IOException, ItemNotFoundException {
262
263        LOGGER.info("GET resource '{}'", externalPath());
264
265        final FedoraId originalId = identifierConverter().pathToInternalId(externalPath());
266        final FedoraId aclId = originalId.asAcl();
267        final boolean exists = doesResourceExist(transaction(), aclId, false);
268
269        if (!exists) {
270            if (originalId.isRepositoryRoot()) {
271                final String aclUri = identifierConverter().toExternalId(aclId.getFullId());
272
273                final RdfStream defaultRdfStream = DefaultRdfStream.fromModel(createResource(aclUri).asNode(),
274                    getDefaultAcl(aclUri, authPropsConfig.getRootAuthAclPath()));
275                final RdfStream rdfStream = httpRdfService.bodyToExternalStream(aclUri,
276                        defaultRdfStream, identifierConverter());
277                final var output = new RdfNamespacedStream(
278                        rdfStream, namespaceRegistry.getNamespaces());
279                return ok(output).build();
280            }
281
282            throw new ItemNotFoundException(String.format("No ACL found at %s", externalPath));
283        }
284
285        final WebacAcl aclResource = webacAclService.find(transaction(), aclId);
286        checkCacheControlHeaders(request, servletResponse, aclResource, transaction());
287
288        LOGGER.info("GET resource '{}'", externalPath);
289        addResourceHttpHeaders(aclResource);
290        return getContent(getChildrenLimit(), aclResource);
291
292    }
293
294    /**
295     * Deletes an object.
296     *
297     * @return response
298     */
299    @DELETE
300    public Response deleteObject() throws ItemNotFoundException {
301
302        hasRestrictedPath(externalPath);
303        LOGGER.info("Delete resource '{}'", externalPath);
304
305        final FedoraId originalId = identifierConverter().pathToInternalId(externalPath());
306        final FedoraId aclId = originalId.asAcl();
307        try {
308            final var aclResource = getFedoraResource(transaction(), aclId);
309            doInDbTxWithRetry(() -> {
310                deleteResourceService.perform(transaction(), aclResource, getUserPrincipal());
311                transaction().commitIfShortLived();
312            });
313        } catch (final PathNotFoundException exc) {
314            if (originalId.isRepositoryRoot()) {
315                throw new ClientErrorException("The default root ACL is system generated and cannot be deleted. " +
316                        "To override the default root ACL you must PUT a user-defined ACL to this endpoint.",
317                        CONFLICT);
318            }
319            throw new PathNotFoundRuntimeException(exc.getMessage(), exc);
320        } finally {
321            transaction().releaseResourceLocksIfShortLived();
322        }
323        return noContent().build();
324    }
325
326    /**
327     * Retrieve the default root ACL from a user specified location if it exists,
328     * otherwise the one provided by Fedora will be used.
329     * @param baseUri the URI of the default ACL
330     * @param customRootAcl the path to a custom root acl to use, optional
331     * @return Model the rdf model of the default root ACL
332     */
333    public static Model getDefaultAcl(final String baseUri, final java.nio.file.Path customRootAcl) {
334        final Model model = createDefaultModel();
335
336        if (customRootAcl != null && Files.isRegularFile(customRootAcl)) {
337            try {
338                LOGGER.debug("Getting root authorization from file: {}", customRootAcl);
339
340                RDFDataMgr.read(model, customRootAcl.toString(), baseUri, null);
341
342                return model;
343            } catch (final JenaException ex) {
344                throw new RuntimeException("Error parsing the default root ACL " + customRootAcl + ".", ex);
345            }
346        }
347
348        try (final InputStream is = FedoraAcl.class.getResourceAsStream(ROOT_AUTHORIZATION_LOCATION)) {
349            LOGGER.debug("Getting root ACL from classpath: {}", ROOT_AUTHORIZATION_LOCATION);
350
351            return model.read(is, baseUri, TTL.getName());
352        } catch (final IOException ex) {
353            throw new RuntimeException("Error reading the default root Acl " + ROOT_AUTHORIZATION_LOCATION + ".", ex);
354        } catch (final JenaException ex) {
355            throw new RuntimeException("Error parsing the default root ACL " + ROOT_AUTHORIZATION_LOCATION + ".", ex);
356        }
357    }
358}