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