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}