From 8da9237fdbfa1c5f57bba51b9a7db50743171c61 Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sat, 3 Oct 2026 12:46:12 -0400
Subject: [PATCH 1/9] Preserve pasted entry images when publishing
---
.../ui/struts2/editor/EntryEdit.java | 206 ++++++++++++++++++
.../roller/weblogger/util/HTMLSanitizer.java | 17 +-
.../weblogger/util/InlineImageData.java | 156 +++++++++++++
.../resources/ApplicationResources.properties | 3 +
.../roller/weblogger/config/roller.properties | 6 +
app/src/main/webapp/themes/basic/weblog.vm | 2 +-
.../main/webapp/themes/basicmobile/weblog.vm | 2 +-
app/src/main/webapp/themes/fauxcoly/weblog.vm | 3 +-
.../main/webapp/themes/frontpage/_header.vm | 2 +-
app/src/main/webapp/themes/gaurav/std_head.vm | 2 +-
docs/roller-user-guide.adoc | 10 +
11 files changed, 395 insertions(+), 14 deletions(-)
create mode 100644 app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java
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,
From 97db6d2360731e028137346fe5e52c1870949c2b Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sat, 3 Oct 2026 17:17:53 -0400
Subject: [PATCH 2/9] Make the inline image field limit configurable
Add weblog.inlineImages.maxFieldBytes (default 60000). Document the
database column sizes that bound it.
---
.../ui/struts2/editor/EntryEdit.java | 2 +-
.../weblogger/util/InlineImageData.java | 31 +++++++-
.../roller/weblogger/config/roller.properties | 9 ++-
.../weblogger/util/InlineImageDataTest.java | 71 +++++++++++++++++++
docs/roller-user-guide.adoc | 13 ++--
5 files changed, 117 insertions(+), 9 deletions(-)
create mode 100644 app/src/test/java/org/apache/roller/weblogger/util/InlineImageDataTest.java
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 d8770a3f1..3a8690013 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
@@ -411,7 +411,7 @@ private boolean validateInlineImages(List sources,
private boolean inlineFieldFits(String html, List sources) {
if (!sources.isEmpty() && html.getBytes(StandardCharsets.UTF_8).length
- > InlineImageData.MAX_FIELD_BYTES) {
+ > InlineImageData.maxFieldBytes()) {
addError("weblogEdit.inlineImageTooLarge");
return false;
}
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
index c4c463efa..6634c3abf 100644
--- a/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java
+++ b/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java
@@ -23,11 +23,19 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.roller.weblogger.config.WebloggerConfig;
+
/** Image data URLs accepted in entry content and their locations in HTML. */
public final class InlineImageData {
+ private static final Log log = LogFactory.getLog(InlineImageData.class);
+
+ static final String MAX_FIELD_BYTES_PROPERTY = "weblog.inlineImages.maxFieldBytes";
+
// MySQL's TEXT column holds 65,535 bytes. Leave room for the rest of an entry.
- public static final int MAX_FIELD_BYTES = 60000;
+ static final int DEFAULT_MAX_FIELD_BYTES = 60000;
private static final Pattern IMAGE_TAG = Pattern.compile("(?is)]*>");
private static final Pattern SOURCE_ATTRIBUTE = Pattern.compile(
@@ -38,6 +46,25 @@ public final class InlineImageData {
private InlineImageData() {
}
+ /** Largest inline content or summary field, in UTF-8 bytes. */
+ public static int maxFieldBytes() {
+ String value = WebloggerConfig.getProperty(MAX_FIELD_BYTES_PROPERTY);
+ if (value == null || value.trim().isEmpty()) {
+ return DEFAULT_MAX_FIELD_BYTES;
+ }
+ try {
+ int max = Integer.parseInt(value.trim());
+ if (max > 0) {
+ return max;
+ }
+ } catch (NumberFormatException invalid) {
+ // fall through to the default
+ }
+ log.warn("Ignoring invalid " + MAX_FIELD_BYTES_PROPERTY + " value '" + value
+ + "'; using " + DEFAULT_MAX_FIELD_BYTES);
+ return DEFAULT_MAX_FIELD_BYTES;
+ }
+
public static List findSources(String html) {
List sources = new ArrayList<>();
if (html == null) {
@@ -82,7 +109,7 @@ public static boolean exceedsUploadLimit(String value, long maxBytes) {
}
private static Image parse(String value, boolean inline) {
- if (value == null || (inline && value.length() > MAX_FIELD_BYTES)) {
+ if (value == null || (inline && value.length() > maxFieldBytes())) {
return null;
}
Matcher match = DATA_URL.matcher(value);
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 1e30283c1..9a2af878a 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
@@ -339,9 +339,16 @@ 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.
+# cannot upload media.
weblog.inlineImages.preferInline=false
+# Largest entry content or summary field, in UTF-8 bytes, that may hold inline
+# images. The default fits MySQL's TEXT column (65,535 bytes). Derby and DB2
+# columns hold 102,400 characters. On MySQL, alter weblogentry.text and
+# weblogentry.summary to MEDIUMTEXT before raising this value; PostgreSQL,
+# Oracle and SQL Server columns need no change.
+weblog.inlineImages.maxFieldBytes=60000
+
# 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/test/java/org/apache/roller/weblogger/util/InlineImageDataTest.java b/app/src/test/java/org/apache/roller/weblogger/util/InlineImageDataTest.java
new file mode 100644
index 000000000..7ced3efb2
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/util/InlineImageDataTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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 org.apache.roller.weblogger.config.WebloggerConfig;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mockStatic;
+
+class InlineImageDataTest {
+
+ // 1x1 transparent PNG
+ private static final String PNG = "data:image/png;base64,"
+ + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
+
+ @Test
+ void fieldLimitDefaultsWhenUnset() {
+ assertEquals(InlineImageData.DEFAULT_MAX_FIELD_BYTES, withLimit(null));
+ }
+
+ @Test
+ void fieldLimitFollowsTheSetting() {
+ assertEquals(250000, withLimit("250000"));
+ }
+
+ @Test
+ void invalidFieldLimitFallsBackToTheDefault() {
+ assertEquals(InlineImageData.DEFAULT_MAX_FIELD_BYTES, withLimit("big"));
+ assertEquals(InlineImageData.DEFAULT_MAX_FIELD_BYTES, withLimit("0"));
+ assertEquals(InlineImageData.DEFAULT_MAX_FIELD_BYTES, withLimit("-1"));
+ }
+
+ @Test
+ void inlineImagesRespectTheFieldLimit() {
+ try (MockedStatic config = mockStatic(WebloggerConfig.class)) {
+ config.when(() -> WebloggerConfig.getProperty(
+ InlineImageData.MAX_FIELD_BYTES_PROPERTY)).thenReturn("20");
+ assertNull(InlineImageData.parse(PNG));
+
+ config.when(() -> WebloggerConfig.getProperty(
+ InlineImageData.MAX_FIELD_BYTES_PROPERTY)).thenReturn("1000");
+ assertNotNull(InlineImageData.parse(PNG));
+ }
+ }
+
+ private static int withLimit(String value) {
+ try (MockedStatic config = mockStatic(WebloggerConfig.class)) {
+ config.when(() -> WebloggerConfig.getProperty(
+ InlineImageData.MAX_FIELD_BYTES_PROPERTY)).thenReturn(value);
+ return InlineImageData.maxFieldBytes();
+ }
+ }
+}
diff --git a/docs/roller-user-guide.adoc b/docs/roller-user-guide.adoc
index 0a6a0253f..86401d5ea 100644
--- a/docs/roller-user-guide.adoc
+++ b/docs/roller-user-guide.adoc
@@ -404,12 +404,15 @@ image::user-guide-11-blogroll.png[]
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
+If uploads are unavailable, Roller keeps the image in the entry. By default,
+a content or summary field with inline images is limited to 60,000 UTF-8
+bytes, 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
+prefer inline images even when uploads are available, and can change the
+limit with `weblog.inlineImages.maxFieldBytes`. On MySQL, alter the
+`weblogentry.text` and `weblogentry.summary` columns to `MEDIUMTEXT` before
+raising the limit, because a `TEXT` column holds only 65,535 bytes. 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
From ff6654568c9bc54effa59a964cb9d50fa8b3a1fe Mon Sep 17 00:00:00 2001
From: "David M. Johnson"
Date: Sat, 3 Oct 2026 17:25:46 -0400
Subject: [PATCH 3/9] Fix inline image parsing and add tests
Stop an img tag match at the next '<' so scanning stays linear, and read
img attributes in order so src text inside another attribute's quoted
value is not taken for the source. Move entry image handling into
prepareInlineImages() so it can be tested, add tests for parsing, the
sanitizer and the editor save paths, and add the 6.1.7 change log entry.
---
CHANGES.md | 16 ++
.../ui/struts2/editor/EntryEdit.java | 127 ++++++-----
.../weblogger/util/InlineImageData.java | 77 ++++++-
.../editor/EntryEditInlineImagesTest.java | 215 ++++++++++++++++++
.../util/HTMLSanitizerInlineImageTest.java | 50 ++++
.../weblogger/util/InlineImageDataTest.java | 90 +++++++-
6 files changed, 508 insertions(+), 67 deletions(-)
create mode 100644 app/src/test/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEditInlineImagesTest.java
create mode 100644 app/src/test/java/org/apache/roller/weblogger/util/HTMLSanitizerInlineImageTest.java
diff --git a/CHANGES.md b/CHANGES.md
index 2f4ad53f8..d963f8fa7 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,21 @@
# Apache Roller — Changes
+## 6.1.7
+
+### Improvements
+
+- **Pasted entry images are kept when you publish**
+ ([ROL-2184](https://issues.apache.org/jira/browse/ROL-2184)). PNG, JPEG and
+ GIF images pasted or dragged into the rich text editor are saved as media
+ files when the author can upload. Otherwise they are kept inline.
+ - `weblog.inlineImages.preferInline=true` keeps images inline even when
+ uploads are available.
+ - `weblog.inlineImages.maxFieldBytes` (default 60000) limits a content or
+ summary field that has inline images. On MySQL, change the entry columns to
+ `MEDIUMTEXT` before you raise it.
+ - Bundled themes allow `data:` images. Custom themes with their own Content
+ Security Policy need `data:` in `img-src`.
+
## 6.1.6
Initial installation now requires a one-time, cryptographically secure setup token printed to the server log. Bootstrap access closes as soon as setup finishes — when the first administrator is created on a new site, or when the database upgrade completes on an existing one.
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 3a8690013..f2739860a 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
@@ -219,63 +219,9 @@ String save() {
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)) {
+ if (!prepareInlineImages(createdImages)) {
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();
@@ -387,6 +333,77 @@ String save() {
return failedSave();
}
+ /**
+ * Uploads data images in the submitted text and summary as media files, or
+ * keeps them inline when uploads are unavailable. Adds an action error and
+ * returns false if the entry cannot be saved. Media files it creates are
+ * added to createdImages so a later failure can remove them.
+ */
+ // Package-private so EntryEditInlineImagesTest can drive it directly.
+ boolean prepareInlineImages(List createdImages)
+ throws WebloggerException {
+ String submittedText = getBean().getText();
+ String submittedSummary = getBean().getSummary();
+ 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 false;
+ }
+ if (!images.isEmpty()) {
+ if (keepInline) {
+ String inlineText = normalizeInlineSources(submittedText,
+ textImages);
+ String inlineSummary = normalizeInlineSources(submittedSummary,
+ summaryImages);
+ if (!inlineFieldFits(inlineText, textImages)
+ || !inlineFieldFits(inlineSummary, summaryImages)) {
+ return false;
+ }
+ 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 false;
+ }
+ }
+ }
+ return true;
+ }
+
private boolean validateInlineImages(List sources,
Map images, boolean keepInline,
long maxUploadBytes) {
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
index 6634c3abf..7e98a191c 100644
--- a/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java
+++ b/app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java
@@ -37,9 +37,7 @@ public final class InlineImageData {
// MySQL's TEXT column holds 65,535 bytes. Leave room for the rest of an entry.
static final int DEFAULT_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 IMAGE_TAG = Pattern.compile("(?is)]*>");
private static final Pattern DATA_URL = Pattern.compile(
"(?i)^data:image/(png|jpeg|gif);base64,([a-z0-9+/]+={0,2})$");
@@ -72,18 +70,75 @@ public static List findSources(String html) {
}
Matcher image = IMAGE_TAG.matcher(html);
while (image.find()) {
- Matcher source = SOURCE_ATTRIBUTE.matcher(image.group());
- if (!source.find()) {
+ Source source = findSource(image.group(), image.start());
+ if (source != null
+ && source.value.trim().toLowerCase(Locale.ROOT).startsWith("data:")) {
+ sources.add(source);
+ }
+ }
+ return sources;
+ }
+
+ /**
+ * Returns the first src attribute of an img tag. Attributes are read in
+ * order, so text inside another attribute's quoted value is never taken
+ * for a src attribute.
+ */
+ private static Source findSource(String tag, int offset) {
+ int length = tag.length();
+ int i = "') {
+ return null;
+ }
+ int nameStart = i;
+ while (i < length && !isNameEnd(tag.charAt(i))) {
+ i++;
+ }
+ String name = tag.substring(nameStart, i);
+ int afterName = i;
+ while (i < length && Character.isWhitespace(tag.charAt(i))) {
+ i++;
+ }
+ if (i >= length || tag.charAt(i) != '=') {
+ i = afterName;
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));
+ i++;
+ while (i < length && Character.isWhitespace(tag.charAt(i))) {
+ i++;
+ }
+ String value;
+ if (i < length && (tag.charAt(i) == '"' || tag.charAt(i) == '\'')) {
+ char quote = tag.charAt(i);
+ int close = tag.indexOf(quote, i + 1);
+ if (close < 0) {
+ return null;
+ }
+ value = tag.substring(i + 1, close);
+ i = close + 1;
+ } else {
+ int valueStart = i;
+ while (i < length && !Character.isWhitespace(tag.charAt(i))
+ && tag.charAt(i) != '>') {
+ i++;
+ }
+ value = tag.substring(valueStart, i);
+ }
+ if ("src".equalsIgnoreCase(name)) {
+ return new Source(offset + nameStart, offset + i, value);
}
}
- return sources;
+ return null;
+ }
+
+ private static boolean isNameEnd(char c) {
+ return Character.isWhitespace(c) || c == '=' || c == '>' || c == '/';
}
/** Returns null for unsupported, malformed, or oversized image data. */
diff --git a/app/src/test/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEditInlineImagesTest.java b/app/src/test/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEditInlineImagesTest.java
new file mode 100644
index 000000000..31bee5f64
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEditInlineImagesTest.java
@@ -0,0 +1,215 @@
+/*
+ * 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.ui.struts2.editor;
+
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import org.apache.roller.weblogger.business.FileContentManager;
+import org.apache.roller.weblogger.business.MediaFileManager;
+import org.apache.roller.weblogger.business.URLStrategy;
+import org.apache.roller.weblogger.business.Weblogger;
+import org.apache.roller.weblogger.business.WebloggerFactory;
+import org.apache.roller.weblogger.config.WebloggerConfig;
+import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
+import org.apache.roller.weblogger.pojos.MediaFile;
+import org.apache.roller.weblogger.pojos.MediaFileDirectory;
+import org.apache.roller.weblogger.pojos.User;
+import org.apache.roller.weblogger.pojos.Weblog;
+import org.apache.roller.weblogger.pojos.WeblogPermission;
+import org.apache.roller.weblogger.util.RollerMessages;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class EntryEditInlineImagesTest {
+
+ private static final String PNG = "data:image/png;base64,"
+ + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
+
+ private MockedStatic config;
+ private MockedStatic runtimeConfig;
+ private MockedStatic factory;
+ private MediaFileManager mediaManager;
+ private FileContentManager contentManager;
+ private Weblog weblog;
+ private EntryEdit action;
+ private final List created = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ config = mockStatic(WebloggerConfig.class);
+ runtimeConfig = mockStatic(WebloggerRuntimeConfig.class);
+ factory = mockStatic(WebloggerFactory.class);
+ runtimeConfig.when(() -> WebloggerRuntimeConfig.getBooleanProperty("uploads.enabled"))
+ .thenReturn(true);
+ runtimeConfig.when(() -> WebloggerRuntimeConfig.getProperty("uploads.file.maxsize"))
+ .thenReturn("1");
+
+ mediaManager = mock(MediaFileManager.class);
+ contentManager = mock(FileContentManager.class);
+ URLStrategy urls = mock(URLStrategy.class);
+ Weblogger weblogger = mock(Weblogger.class);
+ when(weblogger.getMediaFileManager()).thenReturn(mediaManager);
+ when(weblogger.getFileContentManager()).thenReturn(contentManager);
+ when(weblogger.getUrlStrategy()).thenReturn(urls);
+ factory.when(WebloggerFactory::getWeblogger).thenReturn(weblogger);
+ when(urls.getMediaFileURL(any(), anyString(), anyBoolean()))
+ .thenAnswer(call -> "https://blog.example/media/" + call.getArgument(1));
+ when(mediaManager.getDefaultMediaFileDirectory(any()))
+ .thenReturn(new MediaFileDirectory());
+ when(contentManager.canSave(any(), anyString(), anyString(), anyLong(), any()))
+ .thenReturn(true);
+
+ weblog = mock(Weblog.class);
+ when(weblog.hasUserPermission(any(), eq(WeblogPermission.POST))).thenReturn(true);
+
+ action = spy(new EntryEdit());
+ doAnswer(call -> call.getArgument(0)).when(action).getText(anyString());
+ doAnswer(call -> call.getArgument(0)).when(action).getText(anyString(), anyList());
+ action.setActionWeblog(weblog);
+ action.setAuthenticatedUser(new User());
+ }
+
+ @AfterEach
+ void tearDown() {
+ factory.close();
+ runtimeConfig.close();
+ config.close();
+ }
+
+ @Test
+ void uploadsEachDistinctImageOnceAndRewritesBothFields() throws Exception {
+ action.getBean().setText("