snoopdave commented on code in PR #205:
URL: https://github.com/apache/roller/pull/205#discussion_r4177473855


##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEdit.java:
##########
@@ -298,12 +321,212 @@ 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();
     }
 
+    /**
+     * 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<MediaFile> createdImages)
+            throws WebloggerException {
+        String submittedText = getBean().getText();
+        String submittedSummary = getBean().getSummary();
+        Map<String, InlineImageData.Image> images = new HashMap<>();
+        List<InlineImageData.Source> textImages = 
InlineImageData.findSources(submittedText);
+        List<InlineImageData.Source> 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<String, String> 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<InlineImageData.Source> sources,
+            Map<String, InlineImageData.Image> 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<InlineImageData.Source> 
sources) {
+        if (!sources.isEmpty() && html.getBytes(StandardCharsets.UTF_8).length
+                > InlineImageData.maxFieldBytes()) {
+            addError("weblogEdit.inlineImageTooLarge");
+            return false;
+        }
+        return true;
+    }
+
+    private String normalizeInlineSources(String html,
+            List<InlineImageData.Source> 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<InlineImageData.Source> sources,
+            Map<String, InlineImageData.Image> images,
+            Map<String, String> mediaUrls, MediaFileDirectory directory,
+            MediaFileManager mediaManager, List<MediaFile> 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);

Review Comment:
   🐞Codex Issue: **Important:** Include partially created media in failure 
cleanup. `JPAMediaFileManagerImpl.createMediaFile()` stores and commits the 
media record before calling `saveFileContent()`. If the file write throws, 
execution never reaches `createdImages.add(media)`, so the outer catch cannot 
remove the current image. A failure-injection probe confirmed zero cleanup 
calls for that image. Handle the partial-success case explicitly, either by 
making media creation clean up its committed record on failure or by tracking 
and removing a persisted attempted upload. Cover a file write failure after 
metadata commit, including the first image in an entry.



##########
app/src/main/java/org/apache/roller/weblogger/ui/struts2/editor/EntryEdit.java:
##########
@@ -298,12 +321,212 @@ 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();
     }
 
+    /**
+     * 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<MediaFile> createdImages)
+            throws WebloggerException {
+        String submittedText = getBean().getText();
+        String submittedSummary = getBean().getSummary();
+        Map<String, InlineImageData.Image> images = new HashMap<>();
+        List<InlineImageData.Source> textImages = 
InlineImageData.findSources(submittedText);
+        List<InlineImageData.Source> 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<String, String> 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<InlineImageData.Source> sources,
+            Map<String, InlineImageData.Image> 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<InlineImageData.Source> 
sources) {
+        if (!sources.isEmpty() && html.getBytes(StandardCharsets.UTF_8).length
+                > InlineImageData.maxFieldBytes()) {
+            addError("weblogEdit.inlineImageTooLarge");
+            return false;
+        }
+        return true;
+    }
+
+    private String normalizeInlineSources(String html,
+            List<InlineImageData.Source> 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<InlineImageData.Source> sources,
+            Map<String, InlineImageData.Image> images,
+            Map<String, String> mediaUrls, MediaFileDirectory directory,
+            MediaFileManager mediaManager, List<MediaFile> 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<RollerMessage> 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<MediaFile> 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();

Review Comment:
   🐞Codex Issue: **Blocking:** Roll back the failed entry edit before 
committing media cleanup. For an existing entry, 
`getBean().copyTo(weblogEntry)` mutates the managed entity before operations 
that can throw, such as resolving a category deleted since the edit form was 
loaded. The catch block restores only the bean, then this `flush()` commits the 
same EntityManager transaction (`JPAPersistenceStrategy.flush()` calls 
`commit()`). This can save the failed title/text/status changes while deleting 
the uploaded images referenced by that text. A focused save-path probe with a 
missing category reached this cleanup flush with the new title and rewritten 
media URL still on the entry, despite returning an error. Roll back/release the 
failed edit transaction, then remove committed uploads in a separate 
transaction. Add a persistence regression test asserting that a failed edit 
preserves the stored entry and leaves no new media.



##########
app/src/main/java/org/apache/roller/weblogger/util/InlineImageData.java:
##########
@@ -0,0 +1,238 @@
+/*
+ * 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;
+
+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.
+    static final int DEFAULT_MAX_FIELD_BYTES = 60000;
+
+    private static final Pattern IMAGE_TAG = 
Pattern.compile("(?is)<img\\b[^<>]*>");

Review Comment:
   🐞Codex Issue: **Important:** Scan image tags with awareness of quoted 
attribute values. The `[^<>]*` pattern rejects a valid tag containing `<` in an 
attribute and stops at `>` even inside quotes. For example, `findSources()` 
returns zero for both `<img alt="a > b" src="data:image/png;base64,...">` and 
`<img src="data:image/png;base64,..." alt="a < b">` when supplied a valid PNG. 
`prepareInlineImages()` then reports success without converting the image or 
enforcing the inline field limit. A larger pasted image can remain in the entry 
and be stripped during rendering or fail to fit the database field. Use a 
scanner that respects quotes while retaining linear behavior, and add tests 
with angle brackets before and after `src`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to