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