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