001/**
002 * Copyright 2015 DuraSpace, Inc.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.fcrepo.serialization;
017
018import org.apache.commons.io.IOUtils;
019import org.fcrepo.kernel.models.FedoraResource;
020import org.springframework.stereotype.Component;
021
022import javax.jcr.ImportUUIDBehavior;
023import javax.jcr.Node;
024import javax.jcr.RepositoryException;
025import javax.jcr.Session;
026import javax.xml.namespace.QName;
027import javax.xml.stream.XMLEventReader;
028import javax.xml.stream.XMLInputFactory;
029import javax.xml.stream.XMLStreamException;
030import javax.xml.stream.events.Attribute;
031import javax.xml.stream.events.StartElement;
032import javax.xml.stream.events.XMLEvent;
033import java.io.File;
034import java.io.FileInputStream;
035import java.io.FileNotFoundException;
036import java.io.FileOutputStream;
037import java.io.IOException;
038import java.io.InputStream;
039import java.io.OutputStream;
040
041import static org.fcrepo.kernel.FedoraJcrTypes.FEDORA_BINARY;
042import static org.fcrepo.kernel.FedoraJcrTypes.FEDORA_NON_RDF_SOURCE_DESCRIPTION;
043
044/**
045 * Serialize a FedoraObject using the modeshape-provided JCR/XML format
046 *
047 * @author cbeer
048 */
049@Component
050public class JcrXmlSerializer extends BaseFedoraObjectSerializer {
051
052    @Override
053    public String getKey() {
054        return JCR_XML;
055    }
056
057    @Override
058    public String getMediaType() {
059        return "application/xml";
060    }
061
062    @Override
063    public boolean canSerialize(final FedoraResource resource) {
064        return (!(resource.hasType(FEDORA_BINARY) || resource.hasType(FEDORA_NON_RDF_SOURCE_DESCRIPTION)
065                || resource.isFrozenResource()));
066    }
067
068    @Override
069    /**
070     * Serialize JCR/XML with options for recurse and skipBinary.
071     * @param obj
072     * @param out
073     * @param skipBinary
074     * @param recurse
075     * @throws RepositoryException
076     * @throws IOException
077     */
078    public void serialize(final FedoraResource obj,
079                          final OutputStream out,
080                          final boolean skipBinary,
081                          final boolean recurse)
082            throws RepositoryException, IOException, InvalidSerializationFormatException {
083        if (obj.hasType(FEDORA_BINARY)) {
084            throw new InvalidSerializationFormatException("Cannot serialize decontextualized binary content.");
085        }
086        if (obj.isFrozenResource()) {
087            throw new InvalidSerializationFormatException("Cannot serialize historic versions.");
088        }
089        final Node node = obj.getNode();
090        // jcr/xml export system view implemented for noRecurse:
091        // exportSystemView(String absPath, OutputStream out, boolean skipBinary, boolean noRecurse)
092        node.getSession().exportSystemView(obj.getPath(), out, skipBinary, !recurse);
093    }
094
095    @Override
096    public void deserialize(final Session session, final String path,
097            final InputStream stream) throws RepositoryException, IOException, InvalidSerializationFormatException {
098
099        final File temp = File.createTempFile("fcrepo-unsanitized-input", ".xml");
100        final FileOutputStream fos = new FileOutputStream(temp);
101        try {
102            IOUtils.copy(stream, fos);
103        } finally {
104            IOUtils.closeQuietly(stream);
105            IOUtils.closeQuietly(fos);
106        }
107        validateJCRXML(temp);
108        try (final InputStream tmpInputStream = new TempFileInputStream(temp)) {
109            session.importXML(path, tmpInputStream, ImportUUIDBehavior.IMPORT_UUID_COLLISION_THROW);
110        } catch (UnsupportedOperationException | IllegalArgumentException e) {
111            // These come from ModeShape when there's various problems in the formatting of the XML
112            // that are not caught by JCRXMLValidatingInputStreamBridge.
113            throw new InvalidSerializationFormatException("Invalid JCR/XML."
114                    + (e.getMessage() != null ? " (" + e.getMessage() + ")" : ""));
115        }
116    }
117
118    private void validateJCRXML(final File file) throws InvalidSerializationFormatException, IOException {
119        int depth = 0;
120        try (final FileInputStream fis = new FileInputStream(file)) {
121            final XMLEventReader reader = XMLInputFactory.newFactory().createXMLEventReader(fis);
122            while (reader.hasNext()) {
123                final XMLEvent event = reader.nextEvent();
124                if (event.isStartElement()) {
125                    depth ++;
126                    final StartElement startElement = event.asStartElement();
127                    final Attribute nameAttribute = startElement.getAttributeByName(
128                            new QName("http://www.jcp.org/jcr/sv/1.0", "name"));
129                    if (depth == 1 && nameAttribute != null && "jcr:content".equals(nameAttribute.getValue())) {
130                        throw new InvalidSerializationFormatException(
131                                "Cannot import JCR/XML starting with content node.");
132                    }
133                    if (depth == 1 && nameAttribute != null && "jcr:frozenNode".equals(nameAttribute.getValue())) {
134                        throw new InvalidSerializationFormatException(
135                                "Cannot import historic versions.");
136                    }
137                    final QName name = startElement.getName();
138                    if (!(name.getNamespaceURI().equals("http://www.jcp.org/jcr/sv/1.0")
139                            && (name.getLocalPart().equals("node") || name.getLocalPart().equals("property")
140                            || name.getLocalPart().equals("value")))) {
141                        throw new InvalidSerializationFormatException(
142                                "Unrecognized element \"" + name.toString() + "\", in import XML.");
143                    }
144                } else {
145                    if (event.isEndElement()) {
146                        depth --;
147                    }
148                }
149            }
150            reader.close();
151        } catch (XMLStreamException e) {
152            throw new InvalidSerializationFormatException("Unable to parse XML"
153                    + (e.getMessage() != null ? " (" + e.getMessage() + ")." : "."));
154        }
155    }
156
157    /**
158     * A FileInputStream that deletes the file when closed.
159     */
160    private static final class TempFileInputStream extends FileInputStream {
161
162        private File f;
163
164        /**
165         * A constructor whose passed file's content is exposed by this
166         * TempFileInputStream, and which will be deleted when this
167         * InputStream is closed.
168         * @param f
169         * @throws FileNotFoundException
170         */
171        public TempFileInputStream(final File f) throws FileNotFoundException {
172            super(f);
173            this.f = f;
174        }
175
176        @Override
177        public void close() throws IOException {
178            try {
179                super.close();
180            } finally {
181                if (f != null) {
182                    f.delete();
183                    f = null;
184                }
185            }
186        }
187    }
188}