001/* 002 * Copyright 2010 Anyware Services 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 */ 016package org.ametys.cms.transformation.htmledition; 017 018import java.awt.image.BufferedImage; 019import java.io.ByteArrayInputStream; 020import java.io.IOException; 021import java.io.InputStream; 022import java.net.HttpURLConnection; 023import java.net.MalformedURLException; 024import java.net.URI; 025import java.net.URISyntaxException; 026import java.net.URL; 027import java.util.Date; 028import java.util.HashSet; 029import java.util.Map; 030import java.util.Set; 031import java.util.regex.Matcher; 032import java.util.regex.Pattern; 033 034import org.apache.avalon.framework.context.ContextException; 035import org.apache.avalon.framework.service.ServiceException; 036import org.apache.avalon.framework.service.ServiceManager; 037import org.apache.cocoon.Constants; 038import org.apache.cocoon.components.ContextHelper; 039import org.apache.cocoon.environment.Context; 040import org.apache.cocoon.environment.ObjectModelHelper; 041import org.apache.cocoon.environment.Request; 042import org.apache.cocoon.xml.AttributesImpl; 043import org.apache.commons.io.IOUtils; 044import org.apache.commons.io.output.ByteArrayOutputStream; 045import org.apache.excalibur.source.Source; 046import org.apache.excalibur.source.SourceResolver; 047import org.xml.sax.Attributes; 048import org.xml.sax.SAXException; 049 050import org.ametys.cms.repository.Content; 051import org.ametys.core.upload.Upload; 052import org.ametys.core.upload.UploadManager; 053import org.ametys.core.user.CurrentUserProvider; 054import org.ametys.core.util.ImageHelper; 055import org.ametys.plugins.explorer.resources.Resource; 056import org.ametys.plugins.repository.AmetysObjectResolver; 057import org.ametys.plugins.repository.UnknownAmetysObjectException; 058import org.ametys.plugins.repository.metadata.CompositeMetadata; 059import org.ametys.plugins.repository.metadata.File; 060import org.ametys.plugins.repository.metadata.Folder; 061import org.ametys.plugins.repository.metadata.ModifiableFile; 062import org.ametys.plugins.repository.metadata.ModifiableFolder; 063import org.ametys.plugins.repository.metadata.ModifiableResource; 064import org.ametys.plugins.repository.metadata.ModifiableRichText; 065import org.ametys.plugins.repository.metadata.RichText; 066 067/** 068 * This transformer extracts uploaded files' ids from the incoming HTML for further processing. 069 */ 070public class UploadedDataHTMLEditionHandler extends AbstractHTMLEditionHandler 071{ 072 private static final Pattern __INLINE_IMAGE_MARKER = Pattern.compile("^data:image/(png|jpeg|gif);base64,.*"); 073 074 private UploadManager _uploadManager; 075 private CurrentUserProvider _userProvider; 076 private SourceResolver _resolver; 077 private AmetysObjectResolver _ametysResolver; 078 private Context _cocoonContext; 079 080 private boolean _tagToIgnore; 081 private Set<String> _usedLocalFiles = new HashSet<>(); 082 private ModifiableRichText _richText; 083 private Map _objectModel; 084 085 086 @Override 087 public void contextualize(org.apache.avalon.framework.context.Context context) throws ContextException 088 { 089 super.contextualize(context); 090 _cocoonContext = (Context) _context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT); 091 } 092 093 @Override 094 public void service(ServiceManager sManager) throws ServiceException 095 { 096 super.service(sManager); 097 _uploadManager = (UploadManager) sManager.lookup(UploadManager.ROLE); 098 _userProvider = (CurrentUserProvider) sManager.lookup(CurrentUserProvider.ROLE); 099 _resolver = (SourceResolver) sManager.lookup(SourceResolver.ROLE); 100 _ametysResolver = (AmetysObjectResolver) sManager.lookup(AmetysObjectResolver.ROLE); 101 } 102 103 @Override 104 public void startDocument() throws SAXException 105 { 106 _tagToIgnore = false; 107 _objectModel = ContextHelper.getObjectModel(_context); 108 Map parentContextParameters = (Map) _objectModel.get(ObjectModelHelper.PARENT_CONTEXT); 109 _richText = (ModifiableRichText) parentContextParameters.get("richText"); 110 111 super.startDocument(); 112 } 113 114 @Override 115 public void startElement(String uri, String loc, String raw, Attributes attrs) throws SAXException 116 { 117 if ("img".equals(raw)) 118 { 119 String type = attrs.getValue("data-ametys-type"); 120 121 if ("temp".equals(type)) 122 { 123 Attributes newAttrs = _getAttributesForTemp(attrs); 124 super.startElement(uri, loc, raw, newAttrs); 125 return; 126 } 127 else if ("explorer".equals(type)) 128 { 129 Attributes newAttrs = _processResource(attrs); 130 super.startElement(uri, loc, raw, newAttrs); 131 return; 132 } 133 else if ("local".equals(type)) 134 { 135 Attributes newAttrs = _processLocal(attrs); 136 super.startElement(uri, loc, raw, newAttrs); 137 return; 138 } 139 else if (type == null && !"marker".equals(attrs.getValue("marker"))) 140 { 141 // image is copied from elsewhere, fetch it in the content 142 String src = attrs.getValue("src"); 143 if (src == null) 144 { 145 _tagToIgnore = true; 146 getLogger().warn("Don't know how to fetch image with no src attribute. Image is ignored."); 147 return; 148 } 149 150 String ametys_src = attrs.getValue("data-ametys-src"); 151 152 // The final filename 153 String fileName = null; 154 // The new attributes, will be filled with image width and height. 155 AttributesImpl newAttrs = new AttributesImpl(); 156 157 Matcher m = __INLINE_IMAGE_MARKER.matcher(src); 158 if (m.matches()) 159 { 160 String mimetype = m.group(1); 161 String imageAsBase64 = src.substring(19 + mimetype.length()); 162 byte[] imageAsBytes = org.apache.commons.codec.binary.Base64.decodeBase64(imageAsBase64); 163 fileName = _storeFile("paste." + mimetype, new ByteArrayInputStream(imageAsBytes), null, null); 164 165 try (InputStream is = new ByteArrayInputStream(imageAsBytes)) 166 { 167 _addDimensionAttributes(is, newAttrs); 168 } 169 catch (IOException e) 170 { 171 // Ignore 172 } 173 } 174 else 175 { 176 177 String initialFileName = _getInitialFileName(src); 178 179 if (src.startsWith("/")) 180 { 181 try 182 { 183 fileName = _handleInternalFile(src, newAttrs, initialFileName); 184 } 185 catch (Exception e) 186 { 187 // unable to fetch image, do not keep the img tag 188 _tagToIgnore = true; 189 getLogger().warn("Unable to fetch internal image from URL '" + src + "'. Image is ignored.", e); 190 return; 191 } 192 } 193 else if (src.startsWith("http://") || src.startsWith("https://")) 194 { 195 try 196 { 197 fileName = _handleRemoteFile(src, newAttrs, initialFileName); 198 } 199 catch (Exception e) 200 { 201 // unable to fetch image, do not keep the img tag 202 _tagToIgnore = true; 203 getLogger().warn("Unable to fetch external image from URL '" + src + "'. Image is ignored.", e); 204 return; 205 } 206 } 207 else 208 { 209 _tagToIgnore = true; 210 getLogger().warn("Don't know how to fetch image at '" + src + "'. Image is ignored."); 211 return; 212 } 213 } 214 215 _copyAttributes(attrs, newAttrs); 216 217 newAttrs.addAttribute("", "data-ametys-src", "data-ametys-src", "CDATA", ametys_src.replaceAll("\\.", "/") + ";" + fileName); 218 newAttrs.addAttribute("", "data-ametys-type", "data-ametys-type", "CDATA", "local"); 219 220 super.startElement(uri, loc, raw, newAttrs); 221 return; 222 } 223 } 224 225 super.startElement(uri, loc, raw, attrs); 226 } 227 228 private String _getInitialFileName(String src) 229 { 230 int j = src.lastIndexOf('/'); 231 int k = src.indexOf('?', j); 232 String initialFileName; 233 234 if (k == -1) 235 { 236 initialFileName = src.substring(j + 1); 237 } 238 else 239 { 240 initialFileName = src.substring(j + 1, k); 241 } 242 243 // FIXME CMS-3090 A uploaded image can not contain '_max' or '_crop', replace it by '_Max', '_Crop' 244 return initialFileName.replaceAll("_max", "_Max").replaceAll("_crop", "_Crop"); 245 } 246 247 private String _handleInternalFile(String src, AttributesImpl newAttrs, String initialFileName) throws MalformedURLException, IOException, URISyntaxException 248 { 249 // it may be an internal URL 250 Request request = ContextHelper.getRequest(_context); 251 String contextPath = request.getContextPath(); 252 Source source = null; 253 254 try 255 { 256 String modifiedSrc = src; 257 258 if (src.startsWith(contextPath)) 259 { 260 // it is an Ametys URL 261 // first decode it 262 modifiedSrc = new URI(modifiedSrc).getPath(); 263 264 modifiedSrc = "cocoon:/" + src.substring(contextPath.length()); 265 } 266 else 267 { 268 StringBuilder sb = _getRequestURI(request); 269 270 modifiedSrc = sb.toString() + modifiedSrc; 271 } 272 273 source = _resolver.resolveURI(src); 274 275 try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) 276 { 277 try (InputStream is = source.getInputStream()) 278 { 279 IOUtils.copy(is, bos); 280 } 281 282 String fileName = _storeFile(initialFileName, new ByteArrayInputStream(bos.toByteArray()), null, null); 283 284 try (InputStream is = new ByteArrayInputStream(bos.toByteArray())) 285 { 286 _addDimensionAttributes(is, newAttrs); 287 } 288 289 return fileName; 290 } 291 } 292 finally 293 { 294 if (source != null) 295 { 296 _resolver.release(source); 297 } 298 } 299 300 } 301 302 private String _handleRemoteFile(String src, AttributesImpl newAttrs, String initialFileName) throws MalformedURLException, IOException 303 { 304 String fileName; 305 URL url = new URL(src); 306 HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 307 connection.setConnectTimeout(1000); 308 connection.setReadTimeout(2000); 309 310 try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) 311 { 312 try (InputStream is = connection.getInputStream()) 313 { 314 IOUtils.copy(is, bos); 315 } 316 317 fileName = _storeFile(initialFileName, new ByteArrayInputStream(bos.toByteArray()), null, null); 318 319 try (InputStream is = new ByteArrayInputStream(bos.toByteArray())) 320 { 321 _addDimensionAttributes(is, newAttrs); 322 } 323 } 324 return fileName; 325 } 326 327 /** 328 * Copy the attributes. 329 * @param attrs the attributes to copy. 330 * @param newAttrs the attributes to copy to. 331 */ 332 private void _copyAttributes(Attributes attrs, AttributesImpl newAttrs) 333 { 334 for (int i = 0; i < attrs.getLength(); i++) 335 { 336 String name = attrs.getQName(i); 337 338 if (!"data-ametys-src".equals(name) && !"data-ametys-type".equals(name)) 339 { 340 newAttrs.addAttribute(attrs.getURI(i), attrs.getLocalName(i), name, attrs.getType(i), attrs.getValue(i)); 341 } 342 } 343 } 344 345 /** 346 * Get the cms uri 347 * @param request The request 348 * @return the uri without context path 349 */ 350 private StringBuilder _getRequestURI(Request request) 351 { 352 StringBuilder sb = new StringBuilder(); 353 sb.append(request.getScheme()); 354 sb.append("://"); 355 sb.append(request.getServerName()); 356 357 if (request.isSecure()) 358 { 359 if (request.getServerPort() != 443) 360 { 361 sb.append(":"); 362 sb.append(request.getServerPort()); 363 } 364 } 365 else 366 { 367 if (request.getServerPort() != 80) 368 { 369 sb.append(":"); 370 sb.append(request.getServerPort()); 371 } 372 } 373 return sb; 374 } 375 376 private Attributes _getAttributesForTemp(Attributes attrs) 377 { 378 // data has just been uploaded, must change the value, and store the id for further processing 379 String id = attrs.getValue("data-ametys-temp-src"); 380 String src = attrs.getValue("data-ametys-src"); 381 382 Upload upload = _uploadManager.getUpload(_userProvider.getUser(), id); 383 384 String initialFileName = upload.getFilename(); 385 // FIXME CMS-3090 A uploaded image can not contain '_max', replace it by '_Max' 386 initialFileName = initialFileName.replaceAll("_max", "_Max").replaceAll("_crop", "_Crop"); 387 String fileName = _storeFile(initialFileName, upload.getInputStream(), upload.getMimeType(), upload.getUploadedDate()); 388 389 AttributesImpl newAttrs = new AttributesImpl(); 390 391 _copyAttributes(attrs, newAttrs); 392 393 if (!"marker".equals(attrs.getValue("marker"))) 394 { 395 try (InputStream is = upload.getInputStream()) 396 { 397 _addDimensionAttributes(is, newAttrs); 398 } 399 catch (IOException e) 400 { 401 // Ignore 402 } 403 } 404 405 newAttrs.addAttribute("", "data-ametys-src", "data-ametys-src", "CDATA", src.replaceAll("\\.", "/") + ";" + fileName); 406 newAttrs.addAttribute("", "data-ametys-type", "data-ametys-type", "CDATA", "local"); 407 408 return newAttrs; 409 } 410 411 /** 412 * Store a file as rich text data. 413 * @param initialFileName the initial file name. 414 * @param is an input stream on the file. 415 * @param mimeType the file mime type. 416 * @param lastModified the last modification date. 417 * @return the final file name. 418 */ 419 protected String _storeFile(String initialFileName, InputStream is, String mimeType, Date lastModified) 420 { 421 String fileName = initialFileName; 422 int count = 2; 423 424 while (_richText.getAdditionalDataFolder().hasFile(fileName)) 425 { 426 int i = initialFileName.lastIndexOf('.'); 427 fileName = i == -1 ? initialFileName + '-' + (count++) : initialFileName.substring(0, i) + '-' + (count++) + initialFileName.substring(i); 428 } 429 430 ModifiableFile file = _richText.getAdditionalDataFolder().addFile(fileName); 431 ModifiableResource resource = file.getResource(); 432 resource.setLastModified(lastModified != null ? lastModified : new Date()); 433 434 String finalMimeType = mimeType != null ? mimeType : _cocoonContext.getMimeType(fileName.toLowerCase()); 435 436 resource.setMimeType(finalMimeType != null ? finalMimeType : "application/unknown"); 437 resource.setInputStream(is); 438 439 // store the file usage, so that it won't be deleted immediately 440 _usedLocalFiles.add(fileName); 441 442 return fileName; 443 } 444 445 /** 446 * Process a local file. 447 * @param attrs the img tag attributes. 448 * @return the new img tag attributes. 449 */ 450 protected Attributes _processLocal(Attributes attrs) 451 { 452 // src is of the form contentId@metadataName;fileName 453 String ametys_src = attrs.getValue("data-ametys-src"); 454 int i = ametys_src.indexOf('@'); 455 int j = ametys_src.indexOf(';', i); 456 String id = ametys_src.substring(0, i); 457 String metadataName = ametys_src.substring(i + 1, j); 458 String filename = ametys_src.substring(j + 1); 459 460 if (j == -1) 461 { 462 throw new IllegalArgumentException("A local image from inline editor should have an data-ametys-src attribute of the form <protocol>://<protocol-specific-part>;<filename> : " + ametys_src); 463 } 464 465 _usedLocalFiles.add(filename); 466 467 Content content = _ametysResolver.resolveById(id); 468 Folder folder = _getMeta(content.getMetadataHolder(), metadataName).getAdditionalDataFolder(); 469 File file = folder.getFile(filename); 470 471 AttributesImpl newAttrs = new AttributesImpl(attrs); 472 if (!"marker".equals(attrs.getValue("marker"))) 473 { 474 try (InputStream is = file.getResource().getInputStream()) 475 { 476 _addDimensionAttributes(is, newAttrs); 477 } 478 catch (IOException e) 479 { 480 // Ignore 481 } 482 } 483 484 return newAttrs; 485 } 486 487 /** 488 * Process a resource. 489 * @param attrs the img tag attributes. 490 * @return the new img tag attributes. 491 */ 492 protected Attributes _processResource(Attributes attrs) 493 { 494 String ametys_src = attrs.getValue("data-ametys-src"); 495 496 Resource resource = null; 497 try 498 { 499 resource = _ametysResolver.resolveById(ametys_src); 500 } 501 catch (UnknownAmetysObjectException ex) 502 { 503 getLogger().warn("Link to unexisting resource image " + ametys_src, ex); 504 return attrs; 505 } 506 507 AttributesImpl newAttrs = new AttributesImpl(attrs); 508 if (!"marker".equals(attrs.getValue("marker"))) 509 { 510 try (InputStream is = resource.getInputStream()) 511 { 512 _addDimensionAttributes(is, newAttrs); 513 } 514 catch (IOException e) 515 { 516 // Ignore 517 } 518 } 519 520 return newAttrs; 521 } 522 523 /** 524 * Add an image's width and height to the XML attributes. 525 * @param inputStream an input stream on the image. 526 * @param attrs the attributes to fill. 527 * @throws IOException if an error occurs during reading dimension 528 */ 529 protected void _addDimensionAttributes(InputStream inputStream, AttributesImpl attrs) throws IOException 530 { 531 // We need to call Thumbnail to get image dimension with EXIF orientation tag 532 BufferedImage img = ImageHelper.read(inputStream); 533 if (img != null && attrs.getValue("width") == null) 534 { 535 attrs.addCDATAAttribute("width", Integer.toString(img.getWidth())); 536 } 537 if (img != null && attrs.getValue("height") == null) 538 { 539 attrs.addCDATAAttribute("height", Integer.toString(img.getHeight())); 540 } 541 } 542 543 @Override 544 public void endElement(String uri, String loc, String raw) throws SAXException 545 { 546 if ("img".equals(raw) && _tagToIgnore) 547 { 548 // ignore img tag 549 _tagToIgnore = false; 550 return; 551 } 552 553 super.endElement(uri, loc, raw); 554 } 555 556 @Override 557 public void endDocument() throws SAXException 558 { 559 // removing unused files 560 ModifiableFolder folder = _richText.getAdditionalDataFolder(); 561 for (File file : folder.getFiles()) 562 { 563 String fileName = file.getName(); 564 565 if (!_usedLocalFiles.contains(fileName)) 566 { 567 folder.remove(fileName); 568 } 569 } 570 571 super.endDocument(); 572 } 573 574 /** 575 * Get the rich text meta 576 * @param meta The composite meta 577 * @param metadataName The metadata name (with /) 578 * @return The rich text meta 579 */ 580 protected RichText _getMeta(CompositeMetadata meta, String metadataName) 581 { 582 int pos = metadataName.indexOf("/"); 583 if (pos == -1) 584 { 585 return meta.getRichText(metadataName); 586 } 587 else 588 { 589 return _getMeta(meta.getCompositeMetadata(metadataName.substring(0, pos)), metadataName.substring(pos + 1)); 590 } 591 } 592}