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}