001/*
002 * (C) Copyright 2006-2009 Nuxeo SA (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     Nuxeo - initial API and implementation
018 *
019 * $Id$
020 */
021
022package org.nuxeo.ecm.webdav.resource;
023
024import static javax.ws.rs.core.Response.Status.OK;
025import static javax.ws.rs.core.Response.Status.FORBIDDEN;
026
027import java.io.UnsupportedEncodingException;
028import java.net.URI;
029import java.net.URISyntaxException;
030import java.time.Duration;
031import java.time.Instant;
032import java.util.Calendar;
033import java.util.Date;
034import java.util.HashSet;
035import java.util.Set;
036import java.util.UUID;
037
038import javax.servlet.http.HttpServletRequest;
039import javax.ws.rs.DELETE;
040import javax.ws.rs.HEAD;
041import javax.ws.rs.HeaderParam;
042import javax.ws.rs.Produces;
043import javax.ws.rs.core.Context;
044import javax.ws.rs.core.Response;
045import javax.ws.rs.core.UriInfo;
046
047import net.java.dev.webdav.jaxrs.methods.COPY;
048import net.java.dev.webdav.jaxrs.methods.LOCK;
049import net.java.dev.webdav.jaxrs.methods.MKCOL;
050import net.java.dev.webdav.jaxrs.methods.MOVE;
051import net.java.dev.webdav.jaxrs.methods.PROPPATCH;
052import net.java.dev.webdav.jaxrs.methods.UNLOCK;
053import net.java.dev.webdav.jaxrs.xml.elements.ActiveLock;
054import net.java.dev.webdav.jaxrs.xml.elements.Depth;
055import net.java.dev.webdav.jaxrs.xml.elements.HRef;
056import net.java.dev.webdav.jaxrs.xml.elements.LockRoot;
057import net.java.dev.webdav.jaxrs.xml.elements.LockScope;
058import net.java.dev.webdav.jaxrs.xml.elements.LockToken;
059import net.java.dev.webdav.jaxrs.xml.elements.LockType;
060import net.java.dev.webdav.jaxrs.xml.elements.MultiStatus;
061import net.java.dev.webdav.jaxrs.xml.elements.Owner;
062import net.java.dev.webdav.jaxrs.xml.elements.Prop;
063import net.java.dev.webdav.jaxrs.xml.elements.PropStat;
064import net.java.dev.webdav.jaxrs.xml.elements.Status;
065import net.java.dev.webdav.jaxrs.xml.elements.TimeOut;
066import net.java.dev.webdav.jaxrs.xml.properties.LockDiscovery;
067
068import org.apache.commons.lang3.StringUtils;
069import org.apache.commons.logging.Log;
070import org.apache.commons.logging.LogFactory;
071import org.nuxeo.common.utils.Path;
072import org.nuxeo.ecm.core.api.Blob;
073import org.nuxeo.ecm.core.api.CoreSession;
074import org.nuxeo.ecm.core.api.DocumentModel;
075import org.nuxeo.ecm.core.api.DocumentSecurityException;
076import org.nuxeo.ecm.core.api.NuxeoException;
077import org.nuxeo.ecm.core.api.PathRef;
078import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
079import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
080import org.nuxeo.ecm.webdav.EscapeUtils;
081import org.nuxeo.ecm.webdav.backend.Backend;
082import org.nuxeo.ecm.webdav.backend.BackendHelper;
083import org.nuxeo.ecm.webdav.jaxrs.Win32CreationTime;
084import org.nuxeo.ecm.webdav.jaxrs.Win32FileAttributes;
085import org.nuxeo.ecm.webdav.jaxrs.Win32LastAccessTime;
086import org.nuxeo.ecm.webdav.jaxrs.Win32LastModifiedTime;
087
088/**
089 * An existing resource corresponds to an existing object (folder or file) in the repository.
090 */
091public class ExistingResource extends AbstractResource {
092
093    public static final String READONLY_TOKEN = "readonly";
094
095    public static final String DC_SOURCE = "dc:source";
096
097    public static final String DC_CREATED = "dc:created";
098
099    public static final Duration RECENTLY_CREATED_DELTA = Duration.ofMinutes(1);
100
101    private static final Log log = LogFactory.getLog(ExistingResource.class);
102
103    protected DocumentModel doc;
104
105    protected Backend backend;
106
107    protected ExistingResource(String path, DocumentModel doc, HttpServletRequest request, Backend backend) {
108        super(path, request);
109        this.doc = doc;
110        this.backend = backend;
111    }
112
113    @DELETE
114    public Response delete() {
115        if (backend.isLocked(doc.getRef()) && !backend.canUnlock(doc.getRef())) {
116            return Response.status(423).build();
117        }
118
119        // MS Office does the following to do a save on file.docx:
120        // 1. save to tmp1.tmp
121        // 2. rename file.docx to tmp2.tmp (here we saved the original name file.docx as "move original name")
122        // 3. rename tmp1.tmp to file.docx
123        // 4. remove tmp2.tmp (we're here, and the following code will undo the above logic)
124        String origName;
125        if (isMoveTargetCandidate(name) && (origName = getMoveOriginalName()) != null && !origName.contains("/")) {
126            PathRef origRef = new PathRef(doc.getPath().removeLastSegments(1).append(origName).toString());
127            CoreSession session = backend.getSession();
128            if (session.exists(origRef)) {
129                DocumentModel origDoc = session.getDocument(origRef);
130                if (isRecentlyCreated(origDoc)) {
131                    // origDoc is file.docx and contains the blob that was saved
132                    // Move it to a temporary document that will be the one deleted at the end
133                    String tmpName = UUID.randomUUID().toString() + ".tmp";
134                    origDoc = backend.moveItem(origDoc, origDoc.getParentRef(), tmpName);
135                    backend.saveChanges(); // save after first rename for DBS (for second rename duplicate name check)
136                    // Restore tmp2.tmp back to its original name file.docx
137                    doc = backend.moveItem(doc, doc.getParentRef(), origName);
138                    clearMoveOriginalName();
139                    // Get the blob that was saved and update the restored doc file.docx with it
140                    BlobHolder bh = origDoc.getAdapter(BlobHolder.class);
141                    Blob blob = bh.getBlob();
142                    blob.setFilename(origName);
143                    doc.getAdapter(BlobHolder.class).setBlob(blob);
144                    session.saveDocument(doc);
145                    // Set the temporary document as current doc, which we can now delete
146                    doc = origDoc;
147                }
148            }
149        }
150
151        try {
152            backend.removeItem(doc.getRef());
153            backend.saveChanges();
154            return Response.status(204).build();
155        } catch (DocumentSecurityException e) {
156            log.error("Can't remove item: " + doc.getPathAsString() + e.getMessage());
157            log.debug(e);
158            return Response.status(FORBIDDEN).build();
159        }
160    }
161
162    @COPY
163    public Response copy(@HeaderParam("Destination") String dest, @HeaderParam("Overwrite") String overwrite) {
164        return copyOrMove("COPY", dest, overwrite);
165    }
166
167    @MOVE
168    public Response move(@HeaderParam("Destination") String dest, @HeaderParam("Overwrite") String overwrite) {
169        if (backend.isLocked(doc.getRef()) && !backend.canUnlock(doc.getRef())) {
170            return Response.status(423).build();
171        }
172
173        return copyOrMove("MOVE", dest, overwrite);
174    }
175
176    private static String encode(byte[] bytes, String encoding) {
177        try {
178            return new String(bytes, encoding);
179        } catch (UnsupportedEncodingException e) {
180            throw new NuxeoException("Unsupported encoding " + encoding);
181        }
182    }
183
184    private Response copyOrMove(String method, @HeaderParam("Destination") String destination,
185            @HeaderParam("Overwrite") String overwrite) {
186
187        if (backend.isLocked(doc.getRef()) && !backend.canUnlock(doc.getRef())) {
188            return Response.status(423).build();
189        }
190
191        destination = encode(destination.getBytes(), "ISO-8859-1");
192        try {
193            destination = new URI(destination).getPath();
194        } catch (URISyntaxException e) {
195            throw new NuxeoException(e);
196        }
197
198        Backend root = BackendHelper.getBackend("/", request);
199        Set<String> names = new HashSet<>(root.getVirtualFolderNames());
200        Path destinationPath = new Path(destination);
201        String[] segments = destinationPath.segments();
202        int removeSegments = 0;
203        for (String segment : segments) {
204            if (names.contains(segment)) {
205                break;
206            } else {
207                removeSegments++;
208            }
209        }
210        destinationPath = destinationPath.removeFirstSegments(removeSegments);
211
212        String destPath = destinationPath.toString();
213        String davDestPath = destPath;
214        Backend destinationBackend = BackendHelper.getBackend(davDestPath, request);
215        destPath = destinationBackend.parseLocation(destPath).toString();
216        log.debug("to " + davDestPath);
217
218        // Remove dest if it exists and the Overwrite header is set to "T".
219        int status = 201;
220        if (destinationBackend.exists(davDestPath)) {
221            if ("F".equals(overwrite)) {
222                return Response.status(412).build();
223            }
224            destinationBackend.removeItem(davDestPath);
225            backend.saveChanges();
226            status = 204;
227        }
228
229        // Check if parent exists
230        String destParentPath = getParentPath(destPath);
231        PathRef destParentRef = new PathRef(destParentPath);
232        if (!destinationBackend.exists(getParentPath(davDestPath))) {
233            return Response.status(409).build();
234        }
235
236        if ("COPY".equals(method)) {
237            backend.copyItem(doc, destParentRef);
238        } else if ("MOVE".equals(method)) {
239            if (isMoveTargetCandidate(destPath)) {
240                // MS Office tmp extension, the move may have to be undone later, so save the original name
241                saveMoveOriginalName();
242            }
243            backend.moveItem(doc, destParentRef, getNameFromPath(destPath));
244        }
245
246        backend.saveChanges();
247        return Response.status(status).build();
248    }
249
250    // Properties
251
252    @PROPPATCH
253    @Produces({ "application/xml", "text/xml" })
254    public Response proppatch(@Context UriInfo uriInfo) {
255        if (backend.isLocked(doc.getRef()) && !backend.canUnlock(doc.getRef())) {
256            return Response.status(423).build();
257        }
258
259        /*
260         * JAXBContext jc = Util.getJaxbContext(); Unmarshaller u = jc.createUnmarshaller(); PropertyUpdate
261         * propertyUpdate; try { propertyUpdate = (PropertyUpdate) u.unmarshal(request.getInputStream()); } catch
262         * (JAXBException e) { return Response.status(400).build(); }
263         */
264        // Util.printAsXml(propertyUpdate);
265        /*
266         * List<RemoveOrSet> list = propertyUpdate.list(); final List<PropStat> propStats = new ArrayList<PropStat>();
267         * for (RemoveOrSet set : list) { Prop prop = set.getProp(); List<Object> properties = prop.getProperties(); for
268         * (Object property : properties) { PropStat propStat = new PropStat(new Prop(property), new Status(OK));
269         * propStats.add(propStat); } }
270         */
271
272        // @TODO: patch properties if need.
273        // Fake proppatch response
274        @SuppressWarnings("deprecation")
275        final net.java.dev.webdav.jaxrs.xml.elements.Response response = new net.java.dev.webdav.jaxrs.xml.elements.Response(
276                new HRef(uriInfo.getRequestUri()), null, null, null, new PropStat(new Prop(new Win32CreationTime()),
277                        new Status(OK)), new PropStat(new Prop(new Win32FileAttributes()), new Status(OK)),
278                new PropStat(new Prop(new Win32LastAccessTime()), new Status(OK)), new PropStat(new Prop(
279                        new Win32LastModifiedTime()), new Status(OK)));
280
281        return Response.status(207).entity(new MultiStatus(response)).build();
282    }
283
284    /**
285     * We can't MKCOL over an existing resource.
286     */
287    @MKCOL
288    public Response mkcol() {
289        return Response.status(405).build();
290    }
291
292    @HEAD
293    public Response head() {
294        return Response.status(200).build();
295    }
296
297    @LOCK
298    @Produces({ "application/xml", "text/xml" })
299    public Response lock(@Context UriInfo uriInfo) {
300        String token = null;
301        Prop prop = null;
302        if (backend.isLocked(doc.getRef())) {
303            if (!backend.canUnlock(doc.getRef())) {
304                return Response.status(423).build();
305            } else {
306                token = backend.getCheckoutUser(doc.getRef());
307                prop = new Prop(getLockDiscovery(doc, uriInfo));
308                String codedUrl = "<urn:uuid:" + token + ">";
309                return Response.ok().entity(prop).header("Lock-Token", codedUrl).build();
310            }
311        }
312
313        token = backend.lock(doc.getRef());
314        if (READONLY_TOKEN.equals(token)) {
315            return Response.status(423).build();
316        } else if (StringUtils.isEmpty(token)) {
317            return Response.status(400).build();
318        }
319
320        prop = new Prop(getLockDiscovery(doc, uriInfo));
321
322        backend.saveChanges();
323        String codedUrl = "<urn:uuid:" + token + ">";
324        return Response.ok().entity(prop).header("Lock-Token", codedUrl).build();
325    }
326
327    @UNLOCK
328    @Produces({ "application/xml", "text/xml" })
329    public Response unlock() {
330        if (backend.isLocked(doc.getRef())) {
331            if (!backend.canUnlock(doc.getRef())) {
332                return Response.status(423).build();
333            } else {
334                backend.unlock(doc.getRef());
335                backend.saveChanges();
336                return Response.status(204).build();
337            }
338        } else {
339            // TODO: return an error
340            return Response.status(204).build();
341        }
342    }
343
344    protected LockDiscovery getLockDiscovery(DocumentModel doc, UriInfo uriInfo) {
345        LockDiscovery lockDiscovery = null;
346        if (doc.isLocked()) {
347            String token = backend.getCheckoutUser(doc.getRef());
348            String codedUrl = "<urn:uuid:" + token + ">";
349            lockDiscovery = new LockDiscovery(new ActiveLock(LockScope.EXCLUSIVE, LockType.WRITE, Depth.ZERO,
350                    new Owner(token), new TimeOut(10000L), new LockToken(new HRef(codedUrl)),
351                    new LockRoot(new HRef(uriInfo.getRequestUri()))));
352        }
353        return lockDiscovery;
354    }
355
356    protected PropStatBuilderExt getPropStatBuilderExt(DocumentModel doc, UriInfo uriInfo) {
357        Date lastModified = getTimePropertyWrapper(doc, "dc:modified");
358        Date creationDate = getTimePropertyWrapper(doc, "dc:created");
359        String displayName = EscapeUtils.encodePath(backend.getDisplayName(doc));
360        PropStatBuilderExt props = new PropStatBuilderExt();
361        props.lastModified(lastModified).creationDate(creationDate).displayName(displayName).status(OK);
362        if (doc.isFolder()) {
363            props.isCollection();
364        } else {
365            String mimeType = "application/octet-stream";
366            long size = 0;
367            BlobHolder bh = doc.getAdapter(BlobHolder.class);
368            if (bh != null) {
369                Blob blob = bh.getBlob();
370                if (blob != null) {
371                    size = blob.getLength();
372                    mimeType = blob.getMimeType();
373                }
374            }
375            if (StringUtils.isEmpty(mimeType) || "???".equals(mimeType)) {
376                mimeType = "application/octet-stream";
377            }
378            props.isResource(size, mimeType);
379        }
380        return props;
381    }
382
383    protected Date getTimePropertyWrapper(DocumentModel doc, String name) {
384        Object property;
385        try {
386            property = doc.getPropertyValue(name);
387        } catch (PropertyNotFoundException e) {
388            property = null;
389            log.debug("Can't get property " + name + " from document " + doc.getId());
390        }
391
392        if (property != null) {
393            return ((Calendar) property).getTime();
394        } else {
395            return new Date();
396        }
397    }
398
399    protected boolean isMoveTargetCandidate(String path) {
400        return path.endsWith(".tmp");
401    }
402
403    protected void saveMoveOriginalName() {
404        doc.setPropertyValue(DC_SOURCE, name);
405        doc = backend.getSession().saveDocument(doc);
406    }
407
408    protected String getMoveOriginalName() {
409        return (String) doc.getPropertyValue(DC_SOURCE);
410    }
411
412    protected void clearMoveOriginalName() {
413        doc.setPropertyValue(DC_SOURCE, null);
414    }
415
416    protected boolean isRecentlyCreated(DocumentModel doc) {
417        Calendar created = (Calendar) doc.getPropertyValue(DC_CREATED);
418        return created != null && created.toInstant().isAfter(Instant.now().minus(RECENTLY_CREATED_DELTA));
419    }
420
421}