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.commons.api.rdf;
019
020import static com.google.common.collect.ImmutableList.of;
021import static java.util.Collections.singleton;
022import static org.apache.commons.lang3.StringUtils.EMPTY;
023import static org.apache.commons.lang3.StringUtils.replaceOnce;
024import static org.apache.jena.rdf.model.ResourceFactory.createResource;
025import static org.fcrepo.kernel.modeshape.FedoraResourceImpl.CONTAINER_WEBAC_ACL;
026import static org.fcrepo.kernel.api.RdfLexicon.LDPCV_TIME_MAP;
027import static org.fcrepo.kernel.api.FedoraTypes.FCR_ACL;
028import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
029import static org.fcrepo.kernel.api.FedoraTypes.FCR_VERSIONS;
030import static org.fcrepo.kernel.api.RdfLexicon.FEDORA_DESCRIPTION;
031import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.getJcrSession;
032import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter;
033import static org.fcrepo.kernel.modeshape.services.AbstractService.encodePath;
034import static org.fcrepo.kernel.modeshape.services.AbstractService.decodePath;
035import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getClosestExistingAncestor;
036import static org.fcrepo.kernel.modeshape.utils.NamespaceTools.validatePath;
037import static org.slf4j.LoggerFactory.getLogger;
038import static org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext;
039
040import java.io.UnsupportedEncodingException;
041import java.net.URLDecoder;
042import java.util.ArrayList;
043import java.util.HashMap;
044import java.util.List;
045import java.util.Map;
046import java.util.regex.Matcher;
047import java.util.regex.Pattern;
048
049import javax.jcr.Node;
050import javax.jcr.PathNotFoundException;
051import javax.jcr.RepositoryException;
052import javax.jcr.Session;
053import javax.ws.rs.core.UriBuilder;
054
055import org.fcrepo.http.commons.session.HttpSession;
056import org.fcrepo.kernel.api.FedoraSession;
057import org.fcrepo.kernel.api.exception.IdentifierConversionException;
058import org.fcrepo.kernel.api.exception.InvalidResourceIdentifierException;
059import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
060import org.fcrepo.kernel.api.exception.TombstoneException;
061import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
062import org.fcrepo.kernel.api.models.FedoraResource;
063import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
064import org.fcrepo.kernel.modeshape.TombstoneImpl;
065import org.fcrepo.kernel.modeshape.identifiers.HashConverter;
066import org.fcrepo.kernel.modeshape.identifiers.NamespaceConverter;
067
068import org.apache.jena.rdf.model.Resource;
069import org.glassfish.jersey.uri.UriTemplate;
070import org.slf4j.Logger;
071import org.springframework.context.ApplicationContext;
072
073import com.google.common.base.Converter;
074import com.google.common.collect.Lists;
075
076/**
077 * Convert between Jena Resources and JCR Nodes using a JAX-RS UriBuilder to mediate the
078 * URI translation.
079 *
080 * @author cabeer
081 * @since 10/5/14
082 */
083public class HttpResourceConverter extends IdentifierConverter<Resource,FedoraResource> {
084
085    private static final Logger LOGGER = getLogger(HttpResourceConverter.class);
086
087    // Regex pattern which decomposes a http resource uri into components
088    // The first group determines if it is an fcr:metadata non-rdf source.
089    // The second group determines if the path is for a memento or timemap.
090    // The third group allows for a memento identifier.
091    // The fourth group for allows ACL.
092    // The fifth group allows for any hashed suffixes.
093    private final static Pattern FORWARD_COMPONENT_PATTERN = Pattern.compile(
094            ".*?(/" + FCR_METADATA + ")?(/" + FCR_VERSIONS + "(/\\d{14})?)?(/" + FCR_ACL + ")?(\\#\\S+)?$");
095
096    protected List<Converter<String, String>> translationChain;
097
098    private final FedoraSession session;
099    private final UriBuilder uriBuilder;
100
101    protected Converter<String, String> forward = identity();
102    protected Converter<String, String> reverse = identity();
103
104    private final UriTemplate uriTemplate;
105    private final boolean batch;
106
107    /**
108     * Create a new identifier converter within the given session with the given URI template
109     * @param session the session
110     * @param uriBuilder the uri builder
111     */
112    public HttpResourceConverter(final HttpSession session,
113                                 final UriBuilder uriBuilder) {
114
115        this.session = session.getFedoraSession();
116        this.uriBuilder = uriBuilder;
117        this.batch = session.isBatchSession();
118        this.uriTemplate = new UriTemplate(uriBuilder.toTemplate());
119
120        resetTranslationChain();
121    }
122
123    private UriBuilder uriBuilder() {
124        return UriBuilder.fromUri(uriBuilder.toTemplate());
125    }
126
127    @Override
128    protected FedoraResource doForward(final Resource resource) {
129        final Map<String, String> values = new HashMap<>();
130        final String path = asString(resource, values);
131        final Session jcrSession = getJcrSession(session);
132        final String encodedPath = encodePath(path, session);
133        try {
134            if (path != null) {
135                final Node node = getNode(encodedPath);
136
137                final boolean metadata = values.containsKey("path")
138                        && values.get("path").contains("/" + FCR_METADATA);
139
140                final FedoraResource fedoraResource = nodeConverter.convert(node);
141
142                if (!metadata && fedoraResource instanceof NonRdfSourceDescription) {
143                    return fedoraResource.getDescribedResource();
144                }
145                return fedoraResource;
146            }
147            throw new IdentifierConversionException("Asked to translate a resource " + resource
148                    + " that doesn't match the URI template");
149        } catch (final RepositoryException e) {
150            validatePath(jcrSession, encodedPath);
151
152            if ( e instanceof PathNotFoundException ) {
153                try {
154                    final Node preexistingNode = getClosestExistingAncestor(jcrSession, path);
155                    if (TombstoneImpl.hasMixin(preexistingNode)) {
156                        throw new TombstoneException(new TombstoneImpl(preexistingNode));
157                    }
158                } catch (final RepositoryException inner) {
159                    LOGGER.debug("Error checking for parent tombstones", inner);
160                }
161            }
162            throw new RepositoryRuntimeException(e);
163        }
164    }
165
166    @Override
167    protected Resource doBackward(final FedoraResource resource) {
168        return toDomain(doBackwardPathOnly(resource));
169    }
170
171    @Override
172    public boolean inDomain(final Resource resource) {
173        final Map<String, String> values = new HashMap<>();
174
175        return uriTemplate.match(resource.getURI(), values) && values.containsKey("path") ||
176            isRootWithoutTrailingSlash(resource);
177    }
178
179    @Override
180    public Resource toDomain(final String path) {
181
182        final String realPath;
183        if (path == null) {
184            realPath = "";
185        } else if (path.startsWith("/")) {
186            realPath = path.substring(1);
187        } else {
188            realPath = path;
189        }
190
191        final String decodedPath = decodePath(realPath, session);
192
193        final UriBuilder uri = uriBuilder();
194
195        if (decodedPath.contains("#")) {
196
197            final String[] split = decodedPath.split("#", 2);
198
199            uri.resolveTemplate("path", split[0], false);
200            uri.fragment(split[1]);
201        } else {
202            uri.resolveTemplate("path", decodedPath, false);
203
204        }
205        return createResource(uri.build().toString());
206    }
207
208    @Override
209    public String asString(final Resource resource) {
210        final Map<String, String> values = new HashMap<>();
211
212        return asString(resource, values);
213    }
214
215    /**
216     * Convert the incoming Resource to a JCR path (but don't attempt to load the node).
217     *
218     * @param resource Jena Resource to convert
219     * @param values a map that will receive the matching URI template variables for future use.
220     * @return String of JCR path
221     */
222    private String asString(final Resource resource, final Map<String, String> values) {
223        if (uriTemplate.match(resource.getURI(), values) && values.containsKey("path")) {
224            String path = "/" + values.get("path");
225
226            final Matcher matcher = FORWARD_COMPONENT_PATTERN.matcher(path);
227
228            if (matcher.matches()) {
229                final boolean metadata = matcher.group(1) != null;
230                final boolean versioning = matcher.group(2) != null;
231                final boolean webacAcl = matcher.group(4) != null;
232
233                if (versioning) {
234                    path = replaceOnce(path, "/" + FCR_VERSIONS, "/" + LDPCV_TIME_MAP);
235                }
236
237                if (metadata) {
238                    path = replaceOnce(path, "/" + FCR_METADATA, "/" + FEDORA_DESCRIPTION);
239                }
240
241                if (webacAcl) {
242                    path = replaceOnce(path, "/" + FCR_ACL, "/" + CONTAINER_WEBAC_ACL);
243                }
244            }
245
246            path = forward.convert(path);
247
248            if (path == null) {
249                return null;
250            }
251
252            try {
253                path = URLDecoder.decode(path, "UTF-8");
254            } catch (final UnsupportedEncodingException e) {
255                LOGGER.debug("Unable to URL-decode path " + e + " as UTF-8", e);
256            }
257
258            if (path.isEmpty()) {
259                return "/";
260            }
261
262            // Validate path
263            if (path.contains("//")) {
264                throw new InvalidResourceIdentifierException("Path contains empty element! " + path);
265            }
266            return path;
267        }
268
269        if (isRootWithoutTrailingSlash(resource)) {
270            return "/";
271        }
272
273        return null;
274    }
275
276
277    private Node getNode(final String path) throws RepositoryException {
278        try {
279            return getJcrSession(session).getNode(path);
280        } catch (final IllegalArgumentException ex) {
281            throw new InvalidResourceIdentifierException("Illegal path: " + path);
282        }
283    }
284
285    /**
286     * Get only the resource path to this resource, before embedding it in a full URI
287     * @param resource with desired path
288     * @return path
289     */
290    private String doBackwardPathOnly(final FedoraResource resource) {
291
292        final String path = reverse.convert(resource.getPath());
293        if (path == null) {
294            throw new RepositoryRuntimeException("Unable to process reverse chain for resource " + resource);
295        }
296
297        return convertToExternalPath(path);
298    }
299
300    /**
301     * Converts internal path segments to their external formats.
302     * @param path the internal path
303     * @return the external path
304     */
305    public static  String convertToExternalPath(final String path) {
306        String newPath = replaceOnce(path, "/" + CONTAINER_WEBAC_ACL, "/" + FCR_ACL);
307
308        newPath = replaceOnce(newPath, "/" + LDPCV_TIME_MAP, "/" + FCR_VERSIONS);
309
310        newPath = replaceOnce(newPath, "/" + FEDORA_DESCRIPTION, "/" + FCR_METADATA);
311
312        return newPath;
313    }
314
315    protected void resetTranslationChain() {
316        if (translationChain == null) {
317            translationChain = getTranslationChain();
318            final List<Converter<String, String>> newChain =
319                    new ArrayList<>(singleton(new TransactionIdentifierConverter(session, batch)));
320            newChain.addAll(translationChain);
321            setTranslationChain(newChain);
322        }
323    }
324
325    private void setTranslationChain(final List<Converter<String, String>> chained) {
326
327        translationChain = chained;
328
329        for (final Converter<String, String> t : translationChain) {
330            forward = forward.andThen(t);
331        }
332        for (final Converter<String, String> t : Lists.reverse(translationChain)) {
333            reverse = reverse.andThen(t.reverse());
334        }
335    }
336
337
338    private static final List<Converter<String, String>> minimalTranslationChain =
339            of(new NamespaceConverter(), new HashConverter());
340
341    protected List<Converter<String,String>> getTranslationChain() {
342        final ApplicationContext context = getApplicationContext();
343        if (context != null) {
344            @SuppressWarnings("unchecked")
345            final List<Converter<String,String>> tchain =
346                    getApplicationContext().getBean("translationChain", List.class);
347            return tchain;
348        }
349        return minimalTranslationChain;
350    }
351
352    protected ApplicationContext getApplicationContext() {
353        return getCurrentWebApplicationContext();
354    }
355
356    /**
357     * Translate the current transaction into the identifier
358     */
359    static class TransactionIdentifierConverter extends Converter<String, String> {
360        public static final String TX_PREFIX = "tx:";
361
362        private final FedoraSession session;
363        private final boolean batch;
364
365        public TransactionIdentifierConverter(final FedoraSession session, final boolean batch) {
366            this.session = session;
367            this.batch = batch;
368        }
369
370        @Override
371        protected String doForward(final String path) {
372
373            if (path.contains(TX_PREFIX) && !path.contains(txSegment())) {
374                throw new RepositoryRuntimeException("Path " + path
375                        + " is not in current transaction " +  session.getId());
376            }
377
378            return replaceOnce(path, txSegment(), EMPTY);
379        }
380
381        @Override
382        protected String doBackward(final String path) {
383            return txSegment() + path;
384        }
385
386        private String txSegment() {
387            return batch ? "/" + TX_PREFIX + session.getId() : EMPTY;
388        }
389    }
390
391    private boolean isRootWithoutTrailingSlash(final Resource resource) {
392        final Map<String, String> values = new HashMap<>();
393
394        return uriTemplate.match(resource.getURI() + "/", values) && values.containsKey("path") &&
395            values.get("path").isEmpty();
396    }
397}