diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEdit.java b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEdit.java index 8fda6fcbd..d8770a3f1 100644 --- a/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEdit.java +++ b/app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEdit.java @@ -18,12 +18,18 @@ package org.apache.roller.weblogger.ui.struts2.editor; +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collections; import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; @@ -31,12 +37,17 @@ import org.apache.roller.util.DateUtil; import org.apache.roller.util.RollerConstants; import org.apache.roller.weblogger.WebloggerException; +import org.apache.roller.weblogger.business.MediaFileManager; import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.business.WeblogEntryManager; import org.apache.roller.weblogger.business.plugins.PluginManager; import org.apache.roller.weblogger.business.plugins.entry.WeblogEntryPlugin; import org.apache.roller.weblogger.business.search.IndexManager; +import org.apache.roller.weblogger.config.WebloggerConfig; +import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; import org.apache.roller.weblogger.pojos.GlobalPermission; +import org.apache.roller.weblogger.pojos.MediaFile; +import org.apache.roller.weblogger.pojos.MediaFileDirectory; import org.apache.roller.weblogger.pojos.WeblogCategory; import org.apache.roller.weblogger.pojos.WeblogEntry; import org.apache.roller.weblogger.pojos.WeblogEntry.PubStatus; @@ -47,7 +58,10 @@ import org.apache.roller.weblogger.ui.core.plugins.WeblogEntryEditor; import org.apache.roller.weblogger.ui.struts2.util.UIAction; import org.apache.roller.weblogger.util.EnclosureMetadata; +import org.apache.roller.weblogger.util.InlineImageData; import org.apache.roller.weblogger.util.MailUtil; +import org.apache.roller.weblogger.util.RollerMessages; +import org.apache.roller.weblogger.util.RollerMessages.RollerMessage; import org.apache.roller.weblogger.util.cache.CacheManager; import org.apache.struts2.convention.annotation.AllowedMethods; import org.apache.struts2.interceptor.validation.SkipValidation; @@ -200,7 +214,69 @@ String save() { return failedSave(); } + String submittedText = getBean().getText(); + String submittedSummary = getBean().getSummary(); + List createdImages = new ArrayList<>(); + boolean entrySaved = false; try { + Map images = new HashMap<>(); + List textImages = InlineImageData.findSources(submittedText); + List summaryImages = InlineImageData.findSources(submittedSummary); + boolean keepInline = WebloggerConfig.getBooleanProperty( + "weblog.inlineImages.preferInline") + || !WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled") + || !getActionWeblog().hasUserPermission( + getAuthenticatedUser(), WeblogPermission.POST); + long maxUploadBytes = 0; + if (!keepInline && (!textImages.isEmpty() || !summaryImages.isEmpty())) { + maxUploadBytes = (long) (RollerConstants.ONE_MB_IN_BYTES + * new BigDecimal(WebloggerRuntimeConfig.getProperty( + "uploads.file.maxsize")).doubleValue()); + } + if (!validateInlineImages(textImages, images, keepInline, maxUploadBytes) + || !validateInlineImages(summaryImages, images, keepInline, + maxUploadBytes)) { + return failedSave(); + } + if (!images.isEmpty()) { + if (keepInline) { + String inlineText = normalizeInlineSources(submittedText, + textImages); + String inlineSummary = normalizeInlineSources(submittedSummary, + summaryImages); + if (!inlineFieldFits(inlineText, textImages) + || !inlineFieldFits(inlineSummary, summaryImages)) { + return failedSave(); + } + getBean().setText(inlineText); + getBean().setSummary(inlineSummary); + } else { + MediaFileManager mediaManager = WebloggerFactory.getWeblogger() + .getMediaFileManager(); + MediaFileDirectory directory = mediaManager + .getDefaultMediaFileDirectory(getActionWeblog()); + if (directory == null) { + directory = mediaManager.createDefaultMediaFileDirectory( + getActionWeblog()); + } + Map mediaUrls = new HashMap<>(); + getBean().setText(replaceInlineImages(submittedText, + textImages, images, mediaUrls, directory, + mediaManager, createdImages)); + if (!hasActionErrors()) { + getBean().setSummary(replaceInlineImages(submittedSummary, + summaryImages, images, mediaUrls, directory, + mediaManager, createdImages)); + } + if (hasActionErrors()) { + getBean().setText(submittedText); + getBean().setSummary(submittedSummary); + removeCreatedImages(mediaManager, createdImages); + return failedSave(); + } + } + } + WeblogEntryManager weblogEntryManager = WebloggerFactory.getWeblogger() .getWeblogEntryManager(); @@ -264,6 +340,7 @@ String save() { log.debug("Saving entry"); weblogEntryManager.saveWeblogEntry(weblogEntry); WebloggerFactory.getWeblogger().flush(); + entrySaved = true; // notify search of the new entry if (weblogEntry.isPublished()) { @@ -298,12 +375,141 @@ String save() { } catch (Exception e) { log.error("Error saving new entry", e); + if (!entrySaved) { + getBean().setText(submittedText); + getBean().setSummary(submittedSummary); + removeCreatedImages(WebloggerFactory.getWeblogger() + .getMediaFileManager(), createdImages); + } addError("generic.error.check.logs"); } } return failedSave(); } + private boolean validateInlineImages(List sources, + Map images, boolean keepInline, + long maxUploadBytes) { + for (InlineImageData.Source source : sources) { + if (!keepInline && InlineImageData.exceedsUploadLimit( + source.getValue(), maxUploadBytes)) { + addError("weblogEdit.inlineImageUploadTooLarge"); + return false; + } + InlineImageData.Image image = keepInline + ? InlineImageData.parse(source.getValue()) + : InlineImageData.parseForUpload(source.getValue(), + maxUploadBytes); + if (image == null) { + addError("weblogEdit.inlineImageInvalid"); + return false; + } + images.put(source.getValue(), image); + } + return true; + } + + private boolean inlineFieldFits(String html, List sources) { + if (!sources.isEmpty() && html.getBytes(StandardCharsets.UTF_8).length + > InlineImageData.MAX_FIELD_BYTES) { + addError("weblogEdit.inlineImageTooLarge"); + return false; + } + return true; + } + + private String normalizeInlineSources(String html, + List sources) { + if (html == null || sources.isEmpty()) { + return html; + } + StringBuilder result = new StringBuilder(html.length()); + int cursor = 0; + for (InlineImageData.Source source : sources) { + result.append(html, cursor, source.getStart()); + result.append("src=\"").append(source.getValue()).append('"'); + cursor = source.getEnd(); + } + result.append(html, cursor, html.length()); + return result.toString(); + } + + private String replaceInlineImages(String html, + List sources, + Map images, + Map mediaUrls, MediaFileDirectory directory, + MediaFileManager mediaManager, List createdImages) + throws WebloggerException { + if (html == null || sources.isEmpty()) { + return html; + } + StringBuilder result = new StringBuilder(html.length()); + int cursor = 0; + for (InlineImageData.Source source : sources) { + String url = mediaUrls.get(source.getValue()); + if (url == null) { + InlineImageData.Image image = images.get(source.getValue()); + String name = "entry-image-" + UUID.randomUUID() + "." + + image.getExtension(); + RollerMessages errors = new RollerMessages(); + if (!WebloggerFactory.getWeblogger().getFileContentManager() + .canSave(getActionWeblog(), name, image.getContentType(), + image.getBytes().length, errors)) { + addMediaErrors(errors); + return html; + } + MediaFile media = new MediaFile(); + media.setName(name); + media.setWeblog(getActionWeblog()); + media.setDirectory(directory); + media.setLength(image.getBytes().length); + media.setContentType(image.getContentType()); + media.setInputStream(new ByteArrayInputStream(image.getBytes())); + mediaManager.createMediaFile(getActionWeblog(), media, errors); + if (errors.getErrorCount() > 0) { + addMediaErrors(errors); + return html; + } + createdImages.add(media); + url = media.getPermalink(); + mediaUrls.put(source.getValue(), url); + } + result.append(html, cursor, source.getStart()); + result.append("src=\"").append(url).append('"'); + cursor = source.getEnd(); + } + result.append(html, cursor, html.length()); + return result.toString(); + } + + private void addMediaErrors(RollerMessages errors) { + for (Iterator it = errors.getErrors(); it.hasNext();) { + RollerMessage message = it.next(); + String[] args = message.getArgs(); + addError(message.getKey(), args == null + ? Collections.emptyList() : java.util.Arrays.asList(args)); + } + } + + private void removeCreatedImages(MediaFileManager mediaManager, + List createdImages) { + for (MediaFile image : createdImages) { + try { + mediaManager.removeMediaFile(getActionWeblog(), image); + } catch (WebloggerException cleanupError) { + log.warn("Could not remove an image from a failed entry save", cleanupError); + } + } + if (!createdImages.isEmpty()) { + try { + WebloggerFactory.getWeblogger().flush(); + } catch (WebloggerException cleanupError) { + log.warn("Could not flush image cleanup after a failed entry save", + cleanupError); + } + } + } + EnclosureMetadata validateEnclosure() { if (StringUtils.isEmpty(getBean().getEnclosureURL())) { return null; diff --git a/app/src/main/java/org/apache/roller/weblogger/util/HTMLSanitizer.java b/app/src/main/java/org/apache/roller/weblogger/util/HTMLSanitizer.java index 280e07917..56a8db5e7 100644 --- a/app/src/main/java/org/apache/roller/weblogger/util/HTMLSanitizer.java +++ b/app/src/main/java/org/apache/roller/weblogger/util/HTMLSanitizer.java @@ -211,7 +211,8 @@ public static SanitizeResult sanitizer(String html, Pattern allowedTags, Pattern } else if (tag.matches("img|embed") && "src".equals(attr)) { // String[] customSchemes = {"http", "https"}; - if (new UrlValidator(customSchemes).isValid(val)) { + if (new UrlValidator(customSchemes).isValid(val) + || ("img".equals(tag) && InlineImageData.parse(val) != null)) { foundURL = true; } else { ret.invalidTags.add(attr + " " + val); @@ -374,7 +375,7 @@ public static SanitizeResult sanitizer(String html, Pattern allowedTags, Pattern private static List tokenize(String html) { List tokens = new ArrayList<>(); int pos = 0; - String token = ""; + StringBuilder token = new StringBuilder(); int len = html.length(); while (pos < len) { char c = html.charAt(pos); @@ -385,11 +386,11 @@ private static List tokenize(String html) { if ("", html); @@ -402,11 +403,11 @@ private static List tokenize(String html) { //store the current token if (token.length() > 0) { - tokens.add(token); + tokens.add(token.toString()); } //clear the token - token = ""; + token.setLength(0); // serch the end of <......> int end = moveToMarkerEnd(pos, ">", html); @@ -414,7 +415,7 @@ private static List tokenize(String html) { pos = end; } else { - token = token + c; + token.append(c); pos++; } @@ -422,7 +423,7 @@ private static List tokenize(String html) { //store the last token if (token.length() > 0) { - tokens.add(token); + tokens.add(token.toString()); } return tokens; diff --git a/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java b/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java new file mode 100644 index 000000000..c4c463efa --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.roller.weblogger.util; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Image data URLs accepted in entry content and their locations in HTML. */ +public final class InlineImageData { + + // MySQL's TEXT column holds 65,535 bytes. Leave room for the rest of an entry. + public static final int MAX_FIELD_BYTES = 60000; + + private static final Pattern IMAGE_TAG = Pattern.compile("(?is)]*>"); + private static final Pattern SOURCE_ATTRIBUTE = Pattern.compile( + "(?is)(?]+))"); + private static final Pattern DATA_URL = Pattern.compile( + "(?i)^data:image/(png|jpeg|gif);base64,([a-z0-9+/]+={0,2})$"); + + private InlineImageData() { + } + + public static List findSources(String html) { + List sources = new ArrayList<>(); + if (html == null) { + return sources; + } + Matcher image = IMAGE_TAG.matcher(html); + while (image.find()) { + Matcher source = SOURCE_ATTRIBUTE.matcher(image.group()); + if (!source.find()) { + continue; + } + int group = source.start(1) >= 0 ? 1 : source.start(2) >= 0 ? 2 : 3; + String value = source.group(group); + if (value.trim().toLowerCase(Locale.ROOT).startsWith("data:")) { + sources.add(new Source(image.start() + source.start(), + image.start() + source.end(), value)); + } + } + return sources; + } + + /** Returns null for unsupported, malformed, or oversized image data. */ + public static Image parse(String value) { + return parse(value, true); + } + + /** Entry saves may upload larger images under the configured media limit. */ + public static Image parseForUpload(String value, long maxBytes) { + if (exceedsUploadLimit(value, maxBytes)) { + return null; + } + Image image = parse(value, false); + return image != null && image.bytes.length <= maxBytes ? image : null; + } + + public static boolean exceedsUploadLimit(String value, long maxBytes) { + if (value == null || maxBytes < 0) { + return true; + } + // Base64 expands three bytes to four characters; the prefix is short. + return value.length() > 64 + ((maxBytes + 2) / 3) * 4; + } + + private static Image parse(String value, boolean inline) { + if (value == null || (inline && value.length() > MAX_FIELD_BYTES)) { + return null; + } + Matcher match = DATA_URL.matcher(value); + if (!match.matches()) { + return null; + } + String type = match.group(1).toLowerCase(Locale.ROOT); + try { + byte[] bytes = Base64.getDecoder().decode(match.group(2)); + if (!hasSignature(type, bytes)) { + return null; + } + return new Image(type, bytes); + } catch (IllegalArgumentException invalid) { + return null; + } + } + + private static boolean hasSignature(String type, byte[] bytes) { + if ("png".equals(type)) { + byte[] signature = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10}; + if (bytes.length < signature.length) { + return false; + } + for (int i = 0; i < signature.length; i++) { + if (bytes[i] != signature[i]) { + return false; + } + } + return true; + } + if ("jpeg".equals(type)) { + return bytes.length >= 3 && bytes[0] == (byte) 0xff + && bytes[1] == (byte) 0xd8 && bytes[2] == (byte) 0xff; + } + return bytes.length >= 6 && bytes[0] == 'G' && bytes[1] == 'I' + && bytes[2] == 'F' && bytes[3] == '8' + && (bytes[4] == '7' || bytes[4] == '9') && bytes[5] == 'a'; + } + + public static final class Source { + private final int start; + private final int end; + private final String value; + + private Source(int start, int end, String value) { + this.start = start; + this.end = end; + this.value = value; + } + + public int getStart() { return start; } + public int getEnd() { return end; } + public String getValue() { return value; } + } + + public static final class Image { + private final String type; + private final byte[] bytes; + + private Image(String type, byte[] bytes) { + this.type = type; + this.bytes = bytes; + } + + public String getType() { return type; } + public byte[] getBytes() { return bytes; } + public String getExtension() { return "jpeg".equals(type) ? "jpg" : type; } + public String getContentType() { return "image/" + type; } + } +} diff --git a/app/src/main/resources/ApplicationResources.properties b/app/src/main/resources/ApplicationResources.properties index 1aba85d74..c10d10ff3 100644 --- a/app/src/main/resources/ApplicationResources.properties +++ b/app/src/main/resources/ApplicationResources.properties @@ -1554,6 +1554,9 @@ weblogEdit.draft=Draft weblogEdit.draftEntries=Recent Drafts weblogEdit.deleteEntry=Delete Entry weblogEdit.insertMediaFile=Insert Media File +weblogEdit.inlineImageInvalid=This entry contains an unsupported or invalid embedded image. Use a PNG, JPEG or GIF image, or insert a media file. +weblogEdit.inlineImageTooLarge=This entry has too much embedded image data. Resize the image or ask an administrator to enable media uploads. +weblogEdit.inlineImageUploadTooLarge=This embedded image exceeds the site's media upload size limit. Resize it before saving. weblogEdit.fullPreviewMode=Full Preview weblogEdit.locale=Language weblogEdit.pendingEntries=Pending Entries diff --git a/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties b/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties index cdb7d5252..1e30283c1 100644 --- a/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties +++ b/app/src/main/resources/org/apache/roller/weblogger/config/roller.properties @@ -336,6 +336,12 @@ securelogin.enabled=false # With this settings, all users will have HTML posts sanitized. weblogAdminsUntrusted=true +# Upload pasted PNG, JPEG and GIF entry images as media when possible. +# Set true to retain validated images inline even when uploads are available. +# Inline images are used automatically if uploads are disabled or the author +# cannot upload media. Inline entry fields are limited to 60,000 UTF-8 bytes. +weblog.inlineImages.preferInline=false + # Empty value used for passphrase in roller_user table when LDAP or CMA used; # openid presently generates a random (long) password string instead. users.passwords.externalAuthValue= diff --git a/app/src/main/webapp/themes/basic/weblog.vm b/app/src/main/webapp/themes/basic/weblog.vm index 17f52ad2d..1652ab7a2 100644 --- a/app/src/main/webapp/themes/basic/weblog.vm +++ b/app/src/main/webapp/themes/basic/weblog.vm @@ -3,7 +3,7 @@ - + $model.weblog.name #showAutodiscoveryLinks($model.weblog) #showAnalyticsTrackingCode($model.weblog) diff --git a/app/src/main/webapp/themes/basicmobile/weblog.vm b/app/src/main/webapp/themes/basicmobile/weblog.vm index 88504abad..8cb6de293 100644 --- a/app/src/main/webapp/themes/basicmobile/weblog.vm +++ b/app/src/main/webapp/themes/basicmobile/weblog.vm @@ -3,7 +3,7 @@ - + $model.weblog.name #showAutodiscoveryLinks($model.weblog) #showAnalyticsTrackingCode($model.weblog) diff --git a/app/src/main/webapp/themes/fauxcoly/weblog.vm b/app/src/main/webapp/themes/fauxcoly/weblog.vm index bee525959..b8dbd3171 100644 --- a/app/src/main/webapp/themes/fauxcoly/weblog.vm +++ b/app/src/main/webapp/themes/fauxcoly/weblog.vm @@ -3,7 +3,7 @@ - + #includeTemplate($model.weblog "standard_head") $model.weblog.name: $model.weblog.tagline #showAutodiscoveryLinks($model.weblog) @@ -117,4 +117,3 @@ Click the link below to subscribe via your favorite feed reader:

- diff --git a/app/src/main/webapp/themes/frontpage/_header.vm b/app/src/main/webapp/themes/frontpage/_header.vm index 0e4e77005..5c9cdc5bb 100644 --- a/app/src/main/webapp/themes/frontpage/_header.vm +++ b/app/src/main/webapp/themes/frontpage/_header.vm @@ -3,7 +3,7 @@ - + $model.weblog.name #showAutodiscoveryLinks($model.weblog) diff --git a/app/src/main/webapp/themes/gaurav/std_head.vm b/app/src/main/webapp/themes/gaurav/std_head.vm index 94318dcc7..e68bba6e7 100755 --- a/app/src/main/webapp/themes/gaurav/std_head.vm +++ b/app/src/main/webapp/themes/gaurav/std_head.vm @@ -1,5 +1,5 @@ - + #if ($model.permalink == false) #else diff --git a/docs/roller-user-guide.adoc b/docs/roller-user-guide.adoc index 050b4ce1a..0a6a0253f 100644 --- a/docs/roller-user-guide.adoc +++ b/docs/roller-user-guide.adoc @@ -402,6 +402,16 @@ image::user-guide-11-blogroll.png[] === Uploading images and other files to your weblog +When you paste or drag a local PNG, JPEG or GIF image into the rich text +editor, Roller stores it as a media file when uploads are available to you. +If uploads are unavailable, Roller keeps the image in the entry. Inline images +are limited to 60,000 UTF-8 bytes per content or summary field, including +the surrounding text. If an older entry contains embedded images, saving it +again applies the same rule. An administrator can set +`weblog.inlineImages.preferInline=true` in `roller-custom.properties` to +prefer inline images even when uploads are available. Custom themes with a +Content Security Policy must allow `data:` in `img-src` to show inline images. + If you’d like to upload images or other files for use in your weblog, go to your weblog’s *Create & Edit -> Media Files* page. From there you can upload files, browse and search files. You can also manage your files,