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}