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,