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 _addDimensionAttributes(new ByteArrayInputStream(imageAsBytes), newAttrs); 166 } 167 else 168 { 169 170 int j = src.lastIndexOf('/'); 171 int k = src.indexOf('?', j); 172 String initialFileName; 173 174 if (k == -1) 175 { 176 initialFileName = src.substring(j + 1); 177 } 178 else 179 { 180 initialFileName = src.substring(j + 1, k); 181 } 182 183 // FIXME CMS-3090 A uploaded image can not contain '_max', replace it by '_Max' 184 initialFileName = initialFileName.replaceAll("_max", "_Max"); 185 186 if (src.startsWith("/")) 187 { 188 try 189 { 190 fileName = _handleInternalFile(src, newAttrs, initialFileName); 191 } 192 catch (Exception e) 193 { 194 // unable to fetch image, do not keep the img tag 195 _tagToIgnore = true; 196 getLogger().warn("Unable to fetch internal image from URL '" + src + "'. Image is ignored.", e); 197 return; 198 } 199 } 200 else if (src.startsWith("http://") || src.startsWith("https://")) 201 { 202 try 203 { 204 fileName = _handleRemoteFile(src, newAttrs, initialFileName); 205 } 206 catch (Exception e) 207 { 208 // unable to fetch image, do not keep the img tag 209 _tagToIgnore = true; 210 getLogger().warn("Unable to fetch external image from URL '" + src + "'. Image is ignored.", e); 211 return; 212 } 213 } 214 else 215 { 216 _tagToIgnore = true; 217 getLogger().warn("Don't know how to fetch image at '" + src + "'. Image is ignored."); 218 return; 219 } 220 } 221 222 _copyAttributes(attrs, newAttrs); 223 224 newAttrs.addAttribute("", "data-ametys-src", "data-ametys-src", "CDATA", ametys_src.replaceAll("\\.", "/") + ";" + fileName); 225 newAttrs.addAttribute("", "data-ametys-type", "data-ametys-type", "CDATA", "local"); 226 227 super.startElement(uri, loc, raw, newAttrs); 228 return; 229 } 230 } 231 232 super.startElement(uri, loc, raw, attrs); 233 } 234 235 private String _handleInternalFile(String src, AttributesImpl newAttrs, String initialFileName) throws MalformedURLException, IOException, URISyntaxException 236 { 237 // it may be an internal URL 238 Request request = ContextHelper.getRequest(_context); 239 String contextPath = request.getContextPath(); 240 Source source = null; 241 242 try 243 { 244 String modifiedSrc = src; 245 246 if (src.startsWith(contextPath)) 247 { 248 // it is an Ametys URL 249 // first decode it 250 modifiedSrc = new URI(modifiedSrc).getPath(); 251 252 modifiedSrc = "cocoon:/" + src.substring(contextPath.length()); 253 } 254 else 255 { 256 StringBuilder sb = _getRequestURI(request); 257 258 modifiedSrc = sb.toString() + modifiedSrc; 259 } 260 261 source = _resolver.resolveURI(src); 262 263 try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) 264 { 265 try (InputStream is = source.getInputStream()) 266 { 267 IOUtils.copy(is, bos); 268 } 269 270 String fileName = _storeFile(initialFileName, new ByteArrayInputStream(bos.toByteArray()), null, null); 271 272 _addDimensionAttributes(new ByteArrayInputStream(bos.toByteArray()), newAttrs); 273 274 return fileName; 275 } 276 } 277 finally 278 { 279 if (source != null) 280 { 281 _resolver.release(source); 282 } 283 } 284 285 } 286 287 private String _handleRemoteFile(String src, AttributesImpl newAttrs, String initialFileName) throws MalformedURLException, IOException 288 { 289 String fileName; 290 URL url = new URL(src); 291 HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 292 connection.setConnectTimeout(1000); 293 connection.setReadTimeout(2000); 294 295 try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) 296 { 297 try (InputStream is = connection.getInputStream()) 298 { 299 IOUtils.copy(is, bos); 300 } 301 302 fileName = _storeFile(initialFileName, new ByteArrayInputStream(bos.toByteArray()), null, null); 303 304 _addDimensionAttributes(new ByteArrayInputStream(bos.toByteArray()), newAttrs); 305 } 306 return fileName; 307 } 308 309 /** 310 * Copy the attributes. 311 * @param attrs the attributes to copy. 312 * @param newAttrs the attributes to copy to. 313 */ 314 private void _copyAttributes(Attributes attrs, AttributesImpl newAttrs) 315 { 316 for (int i = 0; i < attrs.getLength(); i++) 317 { 318 String name = attrs.getQName(i); 319 320 if (!"data-ametys-src".equals(name) && !"data-ametys-type".equals(name)) 321 { 322 newAttrs.addAttribute(attrs.getURI(i), attrs.getLocalName(i), name, attrs.getType(i), attrs.getValue(i)); 323 } 324 } 325 } 326 327 /** 328 * Get the cms uri 329 * @param request The request 330 * @return the uri without context path 331 */ 332 private StringBuilder _getRequestURI(Request request) 333 { 334 StringBuilder sb = new StringBuilder(); 335 sb.append(request.getScheme()); 336 sb.append("://"); 337 sb.append(request.getServerName()); 338 339 if (request.isSecure()) 340 { 341 if (request.getServerPort() != 443) 342 { 343 sb.append(":"); 344 sb.append(request.getServerPort()); 345 } 346 } 347 else 348 { 349 if (request.getServerPort() != 80) 350 { 351 sb.append(":"); 352 sb.append(request.getServerPort()); 353 } 354 } 355 return sb; 356 } 357 358 private Attributes _getAttributesForTemp(Attributes attrs) 359 { 360 // data has just been uploaded, must change the value, and store the id for further processing 361 String id = attrs.getValue("data-ametys-temp-src"); 362 String src = attrs.getValue("data-ametys-src"); 363 364 Upload upload = _uploadManager.getUpload(_userProvider.getUser(), id); 365 366 String initialFileName = upload.getFilename(); 367 // FIXME CMS-3090 A uploaded image can not contain '_max', replace it by '_Max' 368 initialFileName = initialFileName.replaceAll("_max", "_Max"); 369 String fileName = _storeFile(initialFileName, upload.getInputStream(), upload.getMimeType(), upload.getUploadedDate()); 370 371 AttributesImpl newAttrs = new AttributesImpl(); 372 373 _copyAttributes(attrs, newAttrs); 374 375 if (!"marker".equals(attrs.getValue("marker"))) 376 { 377 _addDimensionAttributes(upload.getInputStream(), newAttrs); 378 } 379 380 newAttrs.addAttribute("", "data-ametys-src", "data-ametys-src", "CDATA", src.replaceAll("\\.", "/") + ";" + fileName); 381 newAttrs.addAttribute("", "data-ametys-type", "data-ametys-type", "CDATA", "local"); 382 383 return newAttrs; 384 } 385 386 /** 387 * Store a file as rich text data. 388 * @param initialFileName the initial file name. 389 * @param is an input stream on the file. 390 * @param mimeType the file mime type. 391 * @param lastModified the last modification date. 392 * @return the final file name. 393 */ 394 protected String _storeFile(String initialFileName, InputStream is, String mimeType, Date lastModified) 395 { 396 String fileName = initialFileName; 397 int count = 2; 398 399 while (_richText.getAdditionalDataFolder().hasFile(fileName)) 400 { 401 int i = initialFileName.lastIndexOf('.'); 402 fileName = i == -1 ? initialFileName + '-' + (count++) : initialFileName.substring(0, i) + '-' + (count++) + initialFileName.substring(i); 403 } 404 405 ModifiableFile file = _richText.getAdditionalDataFolder().addFile(fileName); 406 ModifiableResource resource = file.getResource(); 407 resource.setLastModified(lastModified != null ? lastModified : new Date()); 408 409 String finalMimeType = mimeType != null ? mimeType : _cocoonContext.getMimeType(fileName.toLowerCase()); 410 411 resource.setMimeType(finalMimeType != null ? finalMimeType : "application/unknown"); 412 resource.setInputStream(is); 413 414 // store the file usage, so that it won't be deleted immediately 415 _usedLocalFiles.add(fileName); 416 417 return fileName; 418 } 419 420 /** 421 * Process a local file. 422 * @param attrs the img tag attributes. 423 * @return the new img tag attributes. 424 */ 425 protected Attributes _processLocal(Attributes attrs) 426 { 427 // src is of the form contentId@metadataName;fileName 428 String ametys_src = attrs.getValue("data-ametys-src"); 429 int i = ametys_src.indexOf('@'); 430 int j = ametys_src.lastIndexOf(';'); 431 String id = ametys_src.substring(0, i); 432 String metadataName = ametys_src.substring(i + 1, j); 433 String filename = ametys_src.substring(j + 1); 434 435 if (j == -1) 436 { 437 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); 438 } 439 440 _usedLocalFiles.add(filename); 441 442 Content content = _ametysResolver.resolveById(id); 443 Folder folder = _getMeta(content.getMetadataHolder(), metadataName).getAdditionalDataFolder(); 444 File file = folder.getFile(filename); 445 446 AttributesImpl newAttrs = new AttributesImpl(attrs); 447 if (!"marker".equals(attrs.getValue("marker"))) 448 { 449 _addDimensionAttributes(file.getResource().getInputStream(), newAttrs); 450 } 451 452 return newAttrs; 453 } 454 455 /** 456 * Process a resource. 457 * @param attrs the img tag attributes. 458 * @return the new img tag attributes. 459 */ 460 protected Attributes _processResource(Attributes attrs) 461 { 462 String ametys_src = attrs.getValue("data-ametys-src"); 463 464 Resource resource = null; 465 try 466 { 467 resource = _ametysResolver.resolveById(ametys_src); 468 } 469 catch (UnknownAmetysObjectException ex) 470 { 471 getLogger().warn("Link to unexisting resource image " + ametys_src, ex); 472 return attrs; 473 } 474 475 AttributesImpl newAttrs = new AttributesImpl(attrs); 476 if (!"marker".equals(attrs.getValue("marker"))) 477 { 478 _addDimensionAttributes(resource.getInputStream(), newAttrs); 479 } 480 481 return newAttrs; 482 } 483 484 /** 485 * Add an image's width and height to the XML attributes. 486 * @param inputStream an input stream on the image. 487 * @param attrs the attributes to fill. 488 */ 489 protected void _addDimensionAttributes(InputStream inputStream, AttributesImpl attrs) 490 { 491 try 492 { 493 // We need to call Thumbnail to get image dimension with EXIF orientation tag 494 BufferedImage img = ImageHelper.read(inputStream); 495 if (img != null && attrs.getValue("width") == null) 496 { 497 attrs.addCDATAAttribute("width", Integer.toString(img.getWidth())); 498 } 499 if (img != null && attrs.getValue("height") == null) 500 { 501 attrs.addCDATAAttribute("height", Integer.toString(img.getHeight())); 502 } 503 } 504 catch (IOException e) 505 { 506 // Ignore. 507 } 508 finally 509 { 510 IOUtils.closeQuietly(inputStream); 511 } 512 } 513 514 @Override 515 public void endElement(String uri, String loc, String raw) throws SAXException 516 { 517 if ("img".equals(raw) && _tagToIgnore) 518 { 519 // ignore img tag 520 _tagToIgnore = false; 521 return; 522 } 523 524 super.endElement(uri, loc, raw); 525 } 526 527 @Override 528 public void endDocument() throws SAXException 529 { 530 // removing unused files 531 ModifiableFolder folder = _richText.getAdditionalDataFolder(); 532 for (File file : folder.getFiles()) 533 { 534 String fileName = file.getName(); 535 536 if (!_usedLocalFiles.contains(fileName)) 537 { 538 folder.remove(fileName); 539 } 540 } 541 542 super.endDocument(); 543 } 544 545 /** 546 * Get the rich text meta 547 * @param meta The composite meta 548 * @param metadataName The metadata name (with /) 549 * @return The rich text meta 550 */ 551 protected RichText _getMeta(CompositeMetadata meta, String metadataName) 552 { 553 int pos = metadataName.indexOf("/"); 554 if (pos == -1) 555 { 556 return meta.getRichText(metadataName); 557 } 558 else 559 { 560 return _getMeta(meta.getCompositeMetadata(metadataName.substring(0, pos)), metadataName.substring(pos + 1)); 561 } 562 } 563}