From 7cfb0d8aadbd66a05ec3320a28a7ac860197507e Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Mon, 5 Jan 2026 19:02:13 +0800 Subject: [PATCH 1/3] feat: support image upload --- packages/comment-widget/package.json | 1 + packages/comment-widget/src/base-form.ts | 9 +- packages/comment-widget/src/comment-editor.ts | 32 ++ .../src/extension/editor-upload.ts | 363 ++++++++++++++++++ packages/comment-widget/src/utils/html.ts | 1 + pnpm-lock.yaml | 14 +- .../comment/widget/CommentWidgetPlugin.java | 17 +- .../halo/comment/widget/IpAddressUtils.java | 53 +++ .../widget/RateLimitExceededException.java | 16 + .../widget/RateLimiterKeyRegistry.java | 43 +++ .../comment/widget/SettingConfigGetter.java | 67 ++-- .../widget/SettingConfigGetterImpl.java | 12 +- .../comment/widget/UploadMediaEndpoint.java | 278 ++++++++++++++ .../resources/extensions/role-templates.yaml | 3 + src/main/resources/extensions/settings.yaml | 26 ++ 15 files changed, 906 insertions(+), 29 deletions(-) create mode 100644 packages/comment-widget/src/extension/editor-upload.ts create mode 100644 src/main/java/run/halo/comment/widget/IpAddressUtils.java create mode 100644 src/main/java/run/halo/comment/widget/RateLimitExceededException.java create mode 100644 src/main/java/run/halo/comment/widget/RateLimiterKeyRegistry.java create mode 100644 src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java diff --git a/packages/comment-widget/package.json b/packages/comment-widget/package.json index cdd530e..4210ac0 100644 --- a/packages/comment-widget/package.json +++ b/packages/comment-widget/package.json @@ -48,6 +48,7 @@ "@tiptap/extensions": "^3.10.4", "@tiptap/pm": "^3.10.4", "@tiptap/starter-kit": "^3.10.4", + "@tiptap/extension-image": "^3.14.0", "dayjs": "^1.11.19", "emoji-mart": "^5.6.0", "es-toolkit": "^1.41.0", diff --git a/packages/comment-widget/src/base-form.ts b/packages/comment-widget/src/base-form.ts index b54aecd..3d024ef 100644 --- a/packages/comment-widget/src/base-form.ts +++ b/packages/comment-widget/src/base-form.ts @@ -25,6 +25,7 @@ import { ofetch } from 'ofetch'; import type { CommentEditor } from './comment-editor'; import { cleanHtml } from './utils/html'; import './base-tooltip'; +import { uploadEditorFiles } from './extension/editor-upload'; export class BaseForm extends LitElement { @consume({ context: baseUrlContext }) @@ -296,7 +297,13 @@ export class BaseForm extends LitElement { `; } - private debouncedSubmit = debounce((data: Record) => { + private debouncedSubmit = debounce(async (data: Record) => { + const uploadedResult = await uploadEditorFiles( + this.editorRef.value?.editor + ); + if (!uploadedResult) { + return; + } const content = cleanHtml(this.editorRef.value?.editor?.getHTML()); const characterCount = this.editorRef.value?.editor?.storage.characterCount.characters(); diff --git a/packages/comment-widget/src/comment-editor.ts b/packages/comment-widget/src/comment-editor.ts index 4f9357d..2471e89 100644 --- a/packages/comment-widget/src/comment-editor.ts +++ b/packages/comment-widget/src/comment-editor.ts @@ -6,9 +6,11 @@ import { repeat } from 'lit/directives/repeat.js'; import './emoji-button'; import contentStyles from './styles/content.css?inline'; import './comment-editor-skeleton'; +import { consume } from '@lit/context'; import { property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { when } from 'lit/directives/when.js'; +import { baseUrlContext } from './context'; import baseStyles from './styles/base'; import { cleanHtml } from './utils/html'; @@ -75,7 +77,19 @@ const actionItems: ActionItem[] = [ }, ]; +const uploadActionItem: ActionItem = { + name: 'upload', + displayName: () => msg('Upload'), + type: 'action', + icon: 'i-mingcute-upload-line', + run: (editor?: Editor) => editor?.chain().focus().uploadFile().run(), +}; + export class CommentEditor extends LitElement { + @consume({ context: baseUrlContext }) + @state() + baseUrl = ''; + @property({ type: String }) placeholder: string | undefined; @@ -101,6 +115,8 @@ export class CommentEditor extends LitElement { 'tiptap-extension-code-block-shiki' ); const { CharacterCount } = await import('@tiptap/extensions'); + const { EditorUpload } = await import('./extension/editor-upload'); + const { Image } = await import('@tiptap/extension-image'); this.loading = false; @@ -124,6 +140,21 @@ export class CommentEditor extends LitElement { }), CharacterCount, + + Image.configure({ + inline: true, + resize: { + enabled: true, + alwaysPreserveAspectRatio: true, + minWidth: 100, + minHeight: 100, + directions: ['bottom-right'], + }, + }), + + EditorUpload.configure({ + baseUrl: this.baseUrl, + }), ], onUpdate: () => { this.requestUpdate(); @@ -187,6 +218,7 @@ export class CommentEditor extends LitElement { this.renderActionItem(item, this.editor) )} ${this.renderActionItem({ type: 'separator' })} + ${this.renderActionItem(uploadActionItem, this.editor)}
  • diff --git a/packages/comment-widget/src/extension/editor-upload.ts b/packages/comment-widget/src/extension/editor-upload.ts new file mode 100644 index 0000000..53601c2 --- /dev/null +++ b/packages/comment-widget/src/extension/editor-upload.ts @@ -0,0 +1,363 @@ +import type { Attachment } from '@halo-dev/api-client'; +import { type Editor, Extension } from '@tiptap/core'; +import Image from '@tiptap/extension-image'; +import type { Node } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { ofetch } from 'ofetch'; +import { ToastManager } from '../lit-toast'; + +export interface EditorUploadOptions { + baseUrl: string; +} + +declare module '@tiptap/core' { + interface Commands { + upload: { + uploadFile: () => ReturnType; + }; + } +} + +export const EditorUpload = Extension.create({ + name: 'upload', + + addOptions() { + return { + baseUrl: '', + }; + }, + + addCommands() { + return { + uploadFile: + () => + ({ editor }: { editor: Editor }) => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = () => { + const files = input.files; + if (files) { + uploadFiles(Array.from(files), this.options.baseUrl).then( + (attachments) => { + handleInsertAttachments({ editor, attachments }); + } + ); + } + }; + input.click(); + return true; + }, + }; + }, + + addGlobalAttributes() { + return [ + { + types: [Image.name], + attributes: { + local: { + default: false, + renderHTML() { + return null; + }, + }, + file: { + default: null, + renderHTML() { + return null; + }, + }, + }, + }, + ]; + }, + + addProseMirrorPlugins() { + const { editor }: { editor: Editor } = this; + + return [ + new Plugin({ + key: new PluginKey('upload'), + props: { + handlePaste: (view, event: ClipboardEvent) => { + if (view.props.editable && !view.props.editable(view.state)) { + return false; + } + + if (!event.clipboardData) { + return false; + } + + const types = event.clipboardData.types; + if (!containsFileClipboardIdentifier(types)) { + return false; + } + + // If the copied content is Excel, do not process it. + if (isExcelPasted(event.clipboardData)) { + return false; + } + + const files = Array.from(event.clipboardData.files); + + if (files.length) { + event.preventDefault(); + files.forEach((file) => { + handleFileEvent({ editor, file }); + }); + return true; + } + + return false; + }, + handleDrop: (view, event) => { + if (view.props.editable && !view.props.editable(view.state)) { + return false; + } + + if (!event.dataTransfer) { + return false; + } + + const hasFiles = event.dataTransfer.files.length > 0; + if (!hasFiles) { + return false; + } + + event.preventDefault(); + + const files = Array.from(event.dataTransfer.files) as File[]; + if (files.length) { + event.preventDefault(); + files.forEach((file: File) => { + // TODO: For drag-and-drop uploaded files, + // perhaps it is necessary to determine the + // current position of the drag-and-drop + // instead of inserting them directly at the cursor. + handleFileEvent({ editor, file }); + }); + return true; + } + + return false; + }, + }, + }), + ]; + }, +}); + +function isExcelPasted(clipboardData: ClipboardEvent['clipboardData']) { + if (!clipboardData) { + return false; + } + + const types = clipboardData.types; + if ( + types.includes('application/vnd.ms-excel') || + types.includes( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + ) { + return true; + } + + if (types.includes('text/html')) { + try { + const html = clipboardData.getData('text/html'); + if ( + html.includes('ProgId="Excel.Sheet"') || + html.includes('xmlns:x="urn:schemas-microsoft-com:office:excel"') || + html.includes('urn:schemas-microsoft-com:office:spreadsheet') || + html.includes('') + ) { + return true; + } + } catch (e) { + console.warn('Failed to read clipboard HTML data:', e); + } + } + + return false; +} + +function containsFileClipboardIdentifier(types: readonly string[]) { + const fileTypes = ['files', 'application/x-moz-file', 'public.file-url']; + return types.some((type) => fileTypes.includes(type.toLowerCase())); +} + +type FileProps = { + file: File; + editor: Editor; +}; + +const handleInsertAttachments = ({ + editor, + attachments, +}: { + editor: Editor; + attachments: Attachment[]; +}) => { + if (!editor || !attachments.length) { + return; + } + + for (const attachment of attachments) { + const mediaType = attachment.spec.mediaType; + const permalink = attachment.status?.permalink; + if (!mediaType) { + continue; + } + if (mediaType.startsWith('image/')) { + const node = editor.view.props.state.schema.nodes[Image.name].create({ + src: permalink, + }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(node)); + } + } +}; + +/** + * Handles file events, determining if the file is an image and triggering the appropriate upload process. + * + * @param {FileProps} { file, editor } - File and editor instances + * @returns {boolean} - True if a file is handled, otherwise false + */ +const handleFileEvent = ({ file, editor }: FileProps) => { + if (!file) { + return false; + } + + if (file.type.startsWith('image/')) { + renderImage({ file, editor }); + return true; + } + + return true; +}; + +const getFileBlobUrl = (file: File) => { + return URL.createObjectURL(file); +}; + +const renderImage = ({ file, editor }: FileProps) => { + const { view } = editor; + const blobUrl = getFileBlobUrl(file); + + const node = view.props.state.schema.nodes[Image.name].create({ + src: blobUrl, + file: file, + local: true, + }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(node)); +}; + +type LocalNode = { + node: Node; + pos: number; + parent: Node | null; + index: number; +}; + +type ErrorResponse = { + title?: string; + detail?: string; + status?: number; + instance?: string; + requestId?: string; + timestamp?: string; + type?: string; +}; + +const getLocalNodes = (editor: Editor) => { + const { state } = editor; + const localNodes: LocalNode[] = []; + state.doc.descendants( + (node: Node, pos: number, parent: Node | null, index: number) => { + if (node.attrs.local) { + localNodes.push({ node, pos, parent, index }); + } + } + ); + return localNodes; +}; + +const uploadFileAndReplaceNode = async ( + editor: Editor, + nodes: LocalNode[], + baseUrl?: string +) => { + try { + const files = Array.from(nodes).map((node) => node.node.attrs.file); + const attachments = await uploadFiles(files, baseUrl); + for (const [index, attachment] of attachments.entries()) { + const permalink = attachment.status?.permalink; + const node = nodes[index]; + if (node) { + let tr = editor.state.tr; + tr = tr.setNodeAttribute(node.pos, 'src', permalink); + editor.view.dispatch(tr); + } + } + return true; + } catch (error) { + const toastManager = new ToastManager(); + toastManager.error(error instanceof Error ? error.message : '未知错误'); + } + + return false; +}; + +const uploadFiles = async ( + files: File[], + baseUrl?: string +): Promise => { + try { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); + + const attachments = await ofetch( + `${baseUrl ?? ''}/apis/api.commentwidget.halo.run/v1alpha1/upload`, + { + method: 'POST', + body: formData, + } + ); + + return attachments; + } catch (error: unknown) { + if (error && typeof error === 'object' && 'data' in error) { + const errorData = (error as { data: ErrorResponse }).data; + const title = errorData?.title || '上传失败'; + const detail = errorData?.detail || ''; + const message = detail ? `${title}: ${detail}` : title; + throw new Error(message); + } + throw new Error('上传失败'); + } +}; + +/** + * Upload all local images in the editor to the server + * @param editor - The TipTap editor instance + * @param baseUrl - The base URL for the upload endpoint + * @returns Promise - True if upload succeeds or no files to upload, false otherwise + */ +export const uploadEditorFiles = async ( + editor: Editor | undefined, + baseUrl?: string +): Promise => { + if (!editor) { + return true; + } + + const localNodes = getLocalNodes(editor); + if (localNodes.length === 0) { + return true; + } + + return await uploadFileAndReplaceNode(editor, localNodes, baseUrl); +}; diff --git a/packages/comment-widget/src/utils/html.ts b/packages/comment-widget/src/utils/html.ts index f20202a..bc9cc87 100644 --- a/packages/comment-widget/src/utils/html.ts +++ b/packages/comment-widget/src/utils/html.ts @@ -5,6 +5,7 @@ export function cleanHtml(content?: string) { } return sanitizeHtml(content, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, code: ['class'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2da9d10..46f9acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@tiptap/core': specifier: ^3.10.4 version: 3.10.4(@tiptap/pm@3.10.4) + '@tiptap/extension-image': + specifier: ^3.14.0 + version: 3.14.0(@tiptap/core@3.10.4(@tiptap/pm@3.10.4)) '@tiptap/extensions': specifier: ^3.10.4 version: 3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))(@tiptap/pm@3.10.4) @@ -132,7 +135,7 @@ importers: version: 2.21.0(vue-router@4.5.1(vue@3.5.24(typescript@5.8.3)))(vue@3.5.24(typescript@5.8.3)) '@halo-dev/console-shared': specifier: link:/Users/ryanwang/Workspace/github/ruibaby/halo-next/ui/packages/shared - version: link:../../../../ruibaby/halo-next/ui/packages/shared + version: link:../../../../../../ryanwang/Workspace/github/ruibaby/halo-next/ui/packages/shared vue: specifier: ^3.5.24 version: 3.5.24(typescript@5.8.3) @@ -1068,6 +1071,11 @@ packages: '@tiptap/core': ^3.10.4 '@tiptap/pm': ^3.10.4 + '@tiptap/extension-image@3.14.0': + resolution: {integrity: sha512-lmRU2bhKMDPo+00AiGXZu15jBA9Gmw6QixBWzRrUtsYuFrVAYYCUNIA6mDH7b80935ISqYI+YH1ZlJEmsMptJw==} + peerDependencies: + '@tiptap/core': ^3.14.0 + '@tiptap/extension-italic@3.10.4': resolution: {integrity: sha512-SlvKzL/oUxZ0s+1idv4ZdKmr4tS6m8jbXZYVZlzlRHxSfmt85SqUa2hMxmeNXySrHxURQNP6F+KyI6Z26ddFzA==} peerDependencies: @@ -3872,6 +3880,10 @@ snapshots: '@tiptap/core': 3.10.4(@tiptap/pm@3.10.4) '@tiptap/pm': 3.10.4 + '@tiptap/extension-image@3.14.0(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))': + dependencies: + '@tiptap/core': 3.10.4(@tiptap/pm@3.10.4) + '@tiptap/extension-italic@3.10.4(@tiptap/core@3.10.4(@tiptap/pm@3.10.4))': dependencies: '@tiptap/core': 3.10.4(@tiptap/pm@3.10.4) diff --git a/src/main/java/run/halo/comment/widget/CommentWidgetPlugin.java b/src/main/java/run/halo/comment/widget/CommentWidgetPlugin.java index b32caf5..9f413a6 100644 --- a/src/main/java/run/halo/comment/widget/CommentWidgetPlugin.java +++ b/src/main/java/run/halo/comment/widget/CommentWidgetPlugin.java @@ -1,5 +1,6 @@ package run.halo.comment.widget; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import org.springframework.stereotype.Component; import run.halo.app.plugin.BasePlugin; import run.halo.app.plugin.PluginContext; @@ -10,7 +11,21 @@ */ @Component public class CommentWidgetPlugin extends BasePlugin { - public CommentWidgetPlugin(PluginContext pluginContext) { + + private final RateLimiterRegistry rateLimiterRegistry; + private final RateLimiterKeyRegistry rateLimiterKeyRegistry; + + public CommentWidgetPlugin(PluginContext pluginContext, + RateLimiterRegistry rateLimiterRegistry, + RateLimiterKeyRegistry rateLimiterKeyRegistry) { super(pluginContext); + this.rateLimiterRegistry = rateLimiterRegistry; + this.rateLimiterKeyRegistry = rateLimiterKeyRegistry; + } + + @Override + public void stop() { + rateLimiterKeyRegistry.getAllKeys().forEach(rateLimiterRegistry::remove); + rateLimiterKeyRegistry.clear(); } } diff --git a/src/main/java/run/halo/comment/widget/IpAddressUtils.java b/src/main/java/run/halo/comment/widget/IpAddressUtils.java new file mode 100644 index 0000000..1ce02de --- /dev/null +++ b/src/main/java/run/halo/comment/widget/IpAddressUtils.java @@ -0,0 +1,53 @@ +package run.halo.comment.widget; + +import java.net.InetSocketAddress; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.server.ServerRequest; + +/** + * Ip address utils. + * Code from internet. + */ +@Slf4j +public class IpAddressUtils { + public static final String UNKNOWN = "unknown"; + + private static final String[] IP_HEADER_NAMES = { + "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP", + "CF-Connecting-IP", "HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", "HTTP_CLIENT_IP", "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", "HTTP_VIA", "REMOTE_ADDR", + }; + + /** + * Gets the IP address from request. + * + * @param request is server request + * @return IP address if found, otherwise {@link #UNKNOWN}. + */ + public static String getClientIp(ServerRequest request) { + for (String header : IP_HEADER_NAMES) { + String ipList = request.headers().firstHeader(header); + if (StringUtils.hasText(ipList) && !UNKNOWN.equalsIgnoreCase( + ipList)) { + String[] ips = ipList.trim().split("[,;]"); + for (String ip : ips) { + if (StringUtils.hasText(ip) && !UNKNOWN.equalsIgnoreCase( + ip)) { + return ip; + } + } + } + } + var remoteAddress = request.remoteAddress(); + if (remoteAddress.isEmpty()) { + return UNKNOWN; + } + InetSocketAddress inetSocketAddress = remoteAddress.get(); + if (inetSocketAddress.isUnresolved()) { + return UNKNOWN; + } + return inetSocketAddress.getAddress().getHostAddress(); + } +} diff --git a/src/main/java/run/halo/comment/widget/RateLimitExceededException.java b/src/main/java/run/halo/comment/widget/RateLimitExceededException.java new file mode 100644 index 0000000..fc3259a --- /dev/null +++ b/src/main/java/run/halo/comment/widget/RateLimitExceededException.java @@ -0,0 +1,16 @@ +package run.halo.comment.widget; + +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.web.server.ResponseStatusException; + +public class RateLimitExceededException extends ResponseStatusException { + public static final String REQUEST_NOT_PERMITTED_TYPE = + "https://halo.run/probs/request-not-permitted"; + + public RateLimitExceededException(@Nullable Throwable cause) { + super(HttpStatus.TOO_MANY_REQUESTS, "You have exceeded your quota", cause); + setType(URI.create(REQUEST_NOT_PERMITTED_TYPE)); + } +} diff --git a/src/main/java/run/halo/comment/widget/RateLimiterKeyRegistry.java b/src/main/java/run/halo/comment/widget/RateLimiterKeyRegistry.java new file mode 100644 index 0000000..3000d95 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/RateLimiterKeyRegistry.java @@ -0,0 +1,43 @@ +package run.halo.comment.widget; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +/** + * Throttler Key Registry, used for recording and managing all created throttler keys. + */ +@Component +public class RateLimiterKeyRegistry { + + /** + * Store all created throttler keys. + */ + private final Set rateLimiterKeys = ConcurrentHashMap.newKeySet(); + + /** + * Register throttler key. + * + * @param key throttler key + */ + public void register(String key) { + rateLimiterKeys.add(key); + } + + /** + * Get all registered throttler keys. + * + * @return throttler keys + */ + public Set getAllKeys() { + return rateLimiterKeys; + } + + /** + * Clear all registered throttler keys. + */ + public void clear() { + rateLimiterKeys.clear(); + } +} + diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java index 7262892..57489c7 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java @@ -8,66 +8,87 @@ import run.halo.comment.widget.captcha.CaptchaType; public interface SettingConfigGetter { - + /** * Never {@link Mono#empty()}. */ Mono getBasicConfig(); - + /** * Never {@link Mono#empty()}. */ Mono getAvatarConfig(); - + /** * Never {@link Mono#empty()}. */ Mono getSecurityConfig(); - + + Mono getEditorConfig(); + + @Data + class EditorConfig { + public static final String GROUP = "editor"; + private boolean enableUpload = false; + private UploadConfig upload = new UploadConfig(); + } + + @Data + class UploadConfig { + private boolean allowAnonymous = false; + + private UploadAttachment attachment = new UploadAttachment(); + + @Data + static class UploadAttachment { + private String attachmentPolicy; + private String attachmentGroup; + } + } + @Data @Accessors(chain = true) class SecurityConfig { public static final String GROUP = "security"; - - @Getter(onMethod_ = @NonNull) - private CaptchaConfig captcha = CaptchaConfig.empty(); - + + @Getter(onMethod_ = @NonNull) private CaptchaConfig captcha + = CaptchaConfig.empty(); + public SecurityConfig setCaptcha(CaptchaConfig captcha) { this.captcha = (captcha == null ? CaptchaConfig.empty() : captcha); return this; } - + public static SecurityConfig empty() { - return new SecurityConfig() - .setCaptcha(CaptchaConfig.empty()); + return new SecurityConfig().setCaptcha(CaptchaConfig.empty()); } } - + @Data @Accessors(chain = true) class CaptchaConfig { - + private boolean anonymousCommentCaptcha; - - @Getter(onMethod_ = @NonNull) - private CaptchaType type = CaptchaType.ALPHANUMERIC; - + + @Getter(onMethod_ = @NonNull) private CaptchaType type + = CaptchaType.ALPHANUMERIC; + private boolean ignoreCase = true; - + private int captchaLength = 4; - + private int arithmeticRange = 90; - + public CaptchaConfig setType(CaptchaType type) { this.type = (type == null ? CaptchaType.ALPHANUMERIC : type); return this; } - + public static CaptchaConfig empty() { return new CaptchaConfig(); } } - + @Data class BasicConfig { public static final String GROUP = "basic"; @@ -76,7 +97,7 @@ class BasicConfig { private boolean withReplies; private int withReplySize; } - + @Data class AvatarConfig { public static final String GROUP = "avatar"; diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java index 6b4fad3..dfd5692 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java @@ -9,22 +9,28 @@ @RequiredArgsConstructor public class SettingConfigGetterImpl implements SettingConfigGetter { private final ReactiveSettingFetcher settingFetcher; - + @Override public Mono getBasicConfig() { return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class) .defaultIfEmpty(new BasicConfig()); } - + @Override public Mono getAvatarConfig() { return settingFetcher.fetch(AvatarConfig.GROUP, AvatarConfig.class) .defaultIfEmpty(new AvatarConfig()); } - + @Override public Mono getSecurityConfig() { return settingFetcher.fetch(SecurityConfig.GROUP, SecurityConfig.class) .defaultIfEmpty(SecurityConfig.empty()); } + + @Override + public Mono getEditorConfig() { + return settingFetcher.fetch(EditorConfig.GROUP, EditorConfig.class) + .defaultIfEmpty(new EditorConfig()); + } } diff --git a/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java new file mode 100644 index 0000000..941af80 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java @@ -0,0 +1,278 @@ +package run.halo.comment.widget; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; +import static run.halo.app.infra.utils.FileTypeDetectUtils.getFileExtension; + +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.fn.builders.schema.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.http.codec.multipart.Part; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyExtractors; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.extension.service.AttachmentService; +import run.halo.app.extension.GroupVersion; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.AnonymousUserConst; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UploadMediaEndpoint implements CustomEndpoint { + + private final ReactiveExtensionClient client; + private final SettingConfigGetter settingConfigGetter; + private final AttachmentService attachmentService; + private final RateLimiterRegistry rateLimiterRegistry; + private final RateLimiterKeyRegistry rateLimiterKeyRegistry; + + @Override + public RouterFunction endpoint() { + final var tag = "Comment Widget Media Upload"; + return SpringdocRouteBuilder.route().POST("upload", + contentType(MediaType.MULTIPART_FORM_DATA), request -> request.body( + BodyExtractors.toMultipartData()) + .map(UploadRequest::new) + .flatMap(uploadReq -> { + var files = uploadReq.getFiles(); + return uploadAttachments(files); + }) + .flatMap( + attachments -> ServerResponse.ok().bodyValue(attachments)) + .transformDeferred(createIpBasedRateLimiter(request)) + .onErrorMap(RequestNotPermitted.class, + RateLimitExceededException::new + ), builder -> builder.operationId("UploadAttachment") + .tag(tag) + .requestBody(requestBodyBuilder().required(true) + .content(contentBuilder().mediaType( + MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(Builder.schemaBuilder() + .implementation(IUploadRequest.class)))) + .response(responseBuilder().implementation(Attachment.class)) + .build() + ).build(); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion( + "api.commentwidget.halo.run/v1alpha1"); + } + + private Mono> uploadAttachments(List fileParts) { + return validateUploadRequest(fileParts).flatMap( + editorConfig -> validateUploadPermission(editorConfig).then( + Mono.just(editorConfig))).flatMap(editorConfig -> { + var uploadConfig = editorConfig.getUpload(); + return uploadAttachmentsToStorage(fileParts, + uploadConfig.getAttachment() + ); + }); + } + + private Mono validateUploadRequest( + List fileParts) { + if (fileParts.isEmpty()) { + return Mono.error( + new ServerWebInputException("At least one file is required")); + } + + return settingConfigGetter.getEditorConfig().flatMap(editorConfig -> { + if (!editorConfig.isEnableUpload()) { + return Mono.error( + new ResponseStatusException(HttpStatus.BAD_REQUEST, + "File upload feature is not enabled" + )); + } + return Mono.just(editorConfig); + }); + } + + /** + * Validate upload permission (anonymous user permission check). + */ + private Mono validateUploadPermission( + SettingConfigGetter.EditorConfig editorConfig) { + var uploadConfig = editorConfig.getUpload(); + + if (uploadConfig.isAllowAnonymous()) { + return Mono.empty(); + } + + // Anonymous upload is not allowed, check if the current user is an anonymous user + return isAnonymousCommenter().flatMap(isAnonymous -> { + if (isAnonymous) { + return Mono.error( + new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Anonymous users are not allowed to upload files" + )); + } + return Mono.empty(); + }); + } + + /** + * Upload attachments to storage service. If any attachment upload fails, all successfully uploaded attachments will be rolled back and deleted. + */ + private Mono> uploadAttachmentsToStorage( + List fileParts, + SettingConfigGetter.UploadConfig.UploadAttachment uploadAttachment) { + var policyName = uploadAttachment.getAttachmentPolicy(); + var groupName = uploadAttachment.getAttachmentGroup(); + if (StringUtils.isBlank(policyName)) { + return Mono.error( + new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Please configure the upload policy" + )); + } + List uploadedAttachments = new CopyOnWriteArrayList<>(); + + return authenticationConsumerNullable( + authentication -> Flux.fromIterable(fileParts) + // Ensure sequential upload + .concatMap(filePart -> { + String fileName = UUID.randomUUID().toString(); + String extension = getFileExtension(filePart.filename()); + return attachmentService.upload(policyName, groupName, + fileName + extension, filePart.content(), + filePart.headers().getContentType() + ); + }) + .flatMap(this::setPermalinkToAttachment) + .doOnNext(uploadedAttachments::add) + .collectList() + .onErrorResume(error -> rollbackUploadedAttachments( + uploadedAttachments).then(Mono.error(error)))); + } + + /** + * Rollback and delete uploaded attachments. + */ + private Mono rollbackUploadedAttachments( + List attachments) { + if (attachments.isEmpty()) { + return Mono.empty(); + } + + return Flux.fromIterable(attachments).flatMap(attachment -> { + String attachmentName = attachment.getMetadata().getName(); + return attachmentService.delete(attachment) + .flatMap(client::delete) + .onErrorResume(deleteError -> { + log.error( + "Failed to delete attachment {}, please manually clean up", + attachmentName, deleteError + ); + return Mono.empty(); + }); + }).then(); + } + + /** + * Set the permanent link of the attachment. + */ + private Mono setPermalinkToAttachment(Attachment attachment) { + return attachmentService.getPermalink(attachment).doOnNext( + permalink -> { + var status = attachment.getStatus(); + if (status == null) { + status = new Attachment.AttachmentStatus(); + attachment.setStatus(status); + } + status.setPermalink(permalink.toString()); + }).thenReturn(attachment); + } + + private RateLimiterOperator createIpBasedRateLimiter( + ServerRequest request) { + var clientIp = IpAddressUtils.getClientIp(request); + if (IpAddressUtils.UNKNOWN.equalsIgnoreCase(clientIp)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + var rateLimiterKey = "upload-ip-" + clientIp; + var rateLimiter = rateLimiterRegistry.rateLimiter(rateLimiterKey, + new RateLimiterConfig.Builder().limitForPeriod(10) + .limitRefreshPeriod(Duration.ofSeconds(60)) + .build() + ); + rateLimiterKeyRegistry.register(rateLimiterKey); + if (log.isDebugEnabled()) { + var metrics = rateLimiter.getMetrics(); + log.debug( + "Upload with Rate Limiter: {}, available permissions: {}, number of " + + "waiting threads: {}", rateLimiter, + metrics.getAvailablePermissions(), + metrics.getNumberOfWaitingThreads() + ); + } + return RateLimiterOperator.of(rateLimiter); + } + + Mono authenticationConsumerNullable( + Function> func) { + return ReactiveSecurityContextHolder.getContext().map( + SecurityContext::getAuthentication).flatMap(func); + } + + Mono isAnonymousCommenter() { + return ReactiveSecurityContextHolder.getContext().map( + context -> AnonymousUserConst.isAnonymousUser( + context.getAuthentication().getName())).defaultIfEmpty(true); + } + + @Schema(types = "object") + public interface IUploadRequest { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Attachment files, support multiple files") + List getFiles(); + } + + public record UploadRequest(MultiValueMap formData) + implements IUploadRequest { + + @Override + public List getFiles() { + List parts = formData.get("files"); + if (CollectionUtils.isEmpty(parts)) { + throw new ServerWebInputException("No files found"); + } + + return parts.stream().filter(part -> part instanceof FilePart).map( + part -> (FilePart) part).collect(Collectors.toList()); + } + } + +} diff --git a/src/main/resources/extensions/role-templates.yaml b/src/main/resources/extensions/role-templates.yaml index 26a90b4..36dfbea 100644 --- a/src/main/resources/extensions/role-templates.yaml +++ b/src/main/resources/extensions/role-templates.yaml @@ -16,3 +16,6 @@ rules: - apiGroups: [ "api.commentwidget.halo.run" ] resources: [ "config" ] verbs: [ "list" ] + - apiGroups: [ "api.commentwidget.halo.run" ] + resources: [ "upload" ] + verbs: [ "create" ] \ No newline at end of file diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 9fbfe42..d52c93a 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -147,3 +147,29 @@ spec: - $formkit: text name: placeholder label: 自定义评论框占位符 + - $formkit: checkbox + name: enableUpload + id: enableUpload + key: enableUpload + label: 启用媒体上传 + value: false + - $formkit: group + if: "$get(enableUpload).value === true" + name: upload + label: 媒体上传配置 + children: + - $formkit: checkbox + name: allowAnonymous + label: 允许匿名用户上传 + value: false + - $formkit: group + name: attachment + label: 附件配置 + help: 出于安全考虑,建议为所选存储策略限制文件类型和大小,以免被恶意上传文件 + children: + - $formkit: attachmentPolicySelect + name: attachmentPolicy + label: 存储策略 + - $formkit: attachmentGroupSelect + name: attachmentGroup + label: 存储组 From fc60302410d413e084a9b98dac81c9469bd695aa Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Tue, 6 Jan 2026 12:07:48 +0800 Subject: [PATCH 2/3] Enhance image resizing and upload handling in comment widget Reduced minimum image resize dimensions in the editor and improved the upload process by revoking blob URLs after upload. Updated CSS to style image resize handles and wrappers for better UX. Also performed minor code formatting and cleanup in Java backend classes related to settings, rate limiting, and media upload endpoints. --- packages/comment-widget/src/comment-editor.ts | 4 +- .../src/extension/editor-upload.ts | 4 + .../comment-widget/src/styles/content.css | 127 ++++++++++++++++++ .../halo/comment/widget/IpAddressUtils.java | 4 +- .../widget/RateLimitExceededException.java | 2 +- .../comment/widget/SettingConfigGetter.java | 50 +++---- .../widget/SettingConfigGetterImpl.java | 8 +- .../comment/widget/UploadMediaEndpoint.java | 75 ++++++----- 8 files changed, 206 insertions(+), 68 deletions(-) diff --git a/packages/comment-widget/src/comment-editor.ts b/packages/comment-widget/src/comment-editor.ts index 2471e89..b77ea20 100644 --- a/packages/comment-widget/src/comment-editor.ts +++ b/packages/comment-widget/src/comment-editor.ts @@ -146,8 +146,8 @@ export class CommentEditor extends LitElement { resize: { enabled: true, alwaysPreserveAspectRatio: true, - minWidth: 100, - minHeight: 100, + minWidth: 50, + minHeight: 50, directions: ['bottom-right'], }, }), diff --git a/packages/comment-widget/src/extension/editor-upload.ts b/packages/comment-widget/src/extension/editor-upload.ts index 53601c2..7dba8c2 100644 --- a/packages/comment-widget/src/extension/editor-upload.ts +++ b/packages/comment-widget/src/extension/editor-upload.ts @@ -295,9 +295,13 @@ const uploadFileAndReplaceNode = async ( const permalink = attachment.status?.permalink; const node = nodes[index]; if (node) { + const blobUrl = node.node.attrs.src; let tr = editor.state.tr; tr = tr.setNodeAttribute(node.pos, 'src', permalink); editor.view.dispatch(tr); + if (blobUrl.startsWith('blob:')) { + URL.revokeObjectURL(blobUrl); + } } } return true; diff --git a/packages/comment-widget/src/styles/content.css b/packages/comment-widget/src/styles/content.css index f9fc959..9e5f506 100644 --- a/packages/comment-widget/src/styles/content.css +++ b/packages/comment-widget/src/styles/content.css @@ -47,6 +47,7 @@ } .content img { + display: inline; border-style: none; max-width: 100%; box-sizing: content-box; @@ -300,3 +301,129 @@ .content > *:first-child > .heading-element:first-child { margin-top: 0 !important; } + +.content [data-resize-container] { + display: inline-flex !important; +} + +.content [data-resize-handle] { + position: absolute; + z-index: 10; + width: 12px; + height: 12px; + border: none; + background-color: rgba(59, 130, 246, 0.8); + border-radius: 50%; + transition: all 0.2s ease; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + opacity: 0; + + &[data-resize-handle="top-left"] { + top: -4px !important; + left: -4px !important; + cursor: nwse-resize; + } + + &[data-resize-handle="top-right"] { + top: -4px !important; + right: -4px !important; + cursor: nesw-resize; + } + + &[data-resize-handle="bottom-left"] { + bottom: -4px !important; + left: -4px !important; + cursor: nesw-resize; + } + + &[data-resize-handle="bottom-right"] { + bottom: -4px !important; + right: -4px !important; + cursor: nwse-resize; + } + + &[data-resize-handle="top"], + &[data-resize-handle="bottom"] { + width: 40px; + height: 8px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + } + + &[data-resize-handle="top"]::before, + &[data-resize-handle="bottom"]::before { + content: ""; + width: 20px; + height: 3px; + background: rgba(59, 130, 246, 0.8); + border-radius: 2px; + } + + &[data-resize-handle="top"] { + top: -4px; + cursor: ns-resize; + } + + &[data-resize-handle="bottom"] { + bottom: -4px; + cursor: ns-resize; + } + + &[data-resize-handle="left"], + &[data-resize-handle="right"] { + width: 8px; + height: 40px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + } + + &[data-resize-handle="left"]::before, + &[data-resize-handle="right"]::before { + content: ""; + width: 3px; + height: 20px; + background: rgba(59, 130, 246, 0.8); + border-radius: 2px; + } + + &[data-resize-handle="left"] { + left: -4px; + cursor: ew-resize; + } + + &[data-resize-handle="right"] { + right: -4px; + cursor: ew-resize; + } + + &:hover { + border-color: rgba(37, 99, 235, 1); + opacity: 1; + scale: 1.3; + } + + &[data-resize-handle="top"]:hover::before, + &[data-resize-handle="bottom"]:hover::before, + &[data-resize-handle="left"]:hover::before, + &[data-resize-handle="right"]:hover::before { + background: rgba(37, 99, 235, 1); + } +} + +.content [data-resize-state="true"] [data-resize-wrapper] { + outline: 2px solid rgba(59, 130, 246, 0.5); + outline-offset: 1px; + border-radius: 0.25rem; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); + transition: all 0.2s ease; +} + +.content [data-resize-wrapper]:hover [data-resize-handle] { + opacity: 1; +} diff --git a/src/main/java/run/halo/comment/widget/IpAddressUtils.java b/src/main/java/run/halo/comment/widget/IpAddressUtils.java index 1ce02de..0b77371 100644 --- a/src/main/java/run/halo/comment/widget/IpAddressUtils.java +++ b/src/main/java/run/halo/comment/widget/IpAddressUtils.java @@ -12,14 +12,14 @@ @Slf4j public class IpAddressUtils { public static final String UNKNOWN = "unknown"; - + private static final String[] IP_HEADER_NAMES = { "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP", "CF-Connecting-IP", "HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED", "HTTP_X_CLUSTER_CLIENT_IP", "HTTP_CLIENT_IP", "HTTP_FORWARDED_FOR", "HTTP_FORWARDED", "HTTP_VIA", "REMOTE_ADDR", }; - + /** * Gets the IP address from request. * diff --git a/src/main/java/run/halo/comment/widget/RateLimitExceededException.java b/src/main/java/run/halo/comment/widget/RateLimitExceededException.java index fc3259a..d078fb7 100644 --- a/src/main/java/run/halo/comment/widget/RateLimitExceededException.java +++ b/src/main/java/run/halo/comment/widget/RateLimitExceededException.java @@ -8,7 +8,7 @@ public class RateLimitExceededException extends ResponseStatusException { public static final String REQUEST_NOT_PERMITTED_TYPE = "https://halo.run/probs/request-not-permitted"; - + public RateLimitExceededException(@Nullable Throwable cause) { super(HttpStatus.TOO_MANY_REQUESTS, "You have exceeded your quota", cause); setType(URI.create(REQUEST_NOT_PERMITTED_TYPE)); diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java index 57489c7..34b5f77 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java @@ -8,87 +8,89 @@ import run.halo.comment.widget.captcha.CaptchaType; public interface SettingConfigGetter { - + /** * Never {@link Mono#empty()}. */ Mono getBasicConfig(); - + /** * Never {@link Mono#empty()}. */ Mono getAvatarConfig(); - + /** * Never {@link Mono#empty()}. */ Mono getSecurityConfig(); - + Mono getEditorConfig(); - + @Data class EditorConfig { public static final String GROUP = "editor"; private boolean enableUpload = false; private UploadConfig upload = new UploadConfig(); } - + @Data class UploadConfig { private boolean allowAnonymous = false; - + private UploadAttachment attachment = new UploadAttachment(); - + @Data static class UploadAttachment { private String attachmentPolicy; private String attachmentGroup; } } - + @Data @Accessors(chain = true) class SecurityConfig { public static final String GROUP = "security"; - - @Getter(onMethod_ = @NonNull) private CaptchaConfig captcha + + @Getter(onMethod_ = @NonNull) + private CaptchaConfig captcha = CaptchaConfig.empty(); - + public SecurityConfig setCaptcha(CaptchaConfig captcha) { this.captcha = (captcha == null ? CaptchaConfig.empty() : captcha); return this; } - + public static SecurityConfig empty() { return new SecurityConfig().setCaptcha(CaptchaConfig.empty()); } } - + @Data @Accessors(chain = true) class CaptchaConfig { - + private boolean anonymousCommentCaptcha; - - @Getter(onMethod_ = @NonNull) private CaptchaType type + + @Getter(onMethod_ = @NonNull) + private CaptchaType type = CaptchaType.ALPHANUMERIC; - + private boolean ignoreCase = true; - + private int captchaLength = 4; - + private int arithmeticRange = 90; - + public CaptchaConfig setType(CaptchaType type) { this.type = (type == null ? CaptchaType.ALPHANUMERIC : type); return this; } - + public static CaptchaConfig empty() { return new CaptchaConfig(); } } - + @Data class BasicConfig { public static final String GROUP = "basic"; @@ -97,7 +99,7 @@ class BasicConfig { private boolean withReplies; private int withReplySize; } - + @Data class AvatarConfig { public static final String GROUP = "avatar"; diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java index dfd5692..2d4f475 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java @@ -9,25 +9,25 @@ @RequiredArgsConstructor public class SettingConfigGetterImpl implements SettingConfigGetter { private final ReactiveSettingFetcher settingFetcher; - + @Override public Mono getBasicConfig() { return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class) .defaultIfEmpty(new BasicConfig()); } - + @Override public Mono getAvatarConfig() { return settingFetcher.fetch(AvatarConfig.GROUP, AvatarConfig.class) .defaultIfEmpty(new AvatarConfig()); } - + @Override public Mono getSecurityConfig() { return settingFetcher.fetch(SecurityConfig.GROUP, SecurityConfig.class) .defaultIfEmpty(SecurityConfig.empty()); } - + @Override public Mono getEditorConfig() { return settingFetcher.fetch(EditorConfig.GROUP, EditorConfig.class) diff --git a/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java index 941af80..1706446 100644 --- a/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java +++ b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java @@ -51,13 +51,13 @@ @Component @RequiredArgsConstructor public class UploadMediaEndpoint implements CustomEndpoint { - + private final ReactiveExtensionClient client; private final SettingConfigGetter settingConfigGetter; private final AttachmentService attachmentService; private final RateLimiterRegistry rateLimiterRegistry; private final RateLimiterKeyRegistry rateLimiterKeyRegistry; - + @Override public RouterFunction endpoint() { final var tag = "Comment Widget Media Upload"; @@ -85,31 +85,33 @@ public RouterFunction endpoint() { .build() ).build(); } - + @Override public GroupVersion groupVersion() { return GroupVersion.parseAPIVersion( "api.commentwidget.halo.run/v1alpha1"); } - + private Mono> uploadAttachments(List fileParts) { - return validateUploadRequest(fileParts).flatMap( - editorConfig -> validateUploadPermission(editorConfig).then( - Mono.just(editorConfig))).flatMap(editorConfig -> { - var uploadConfig = editorConfig.getUpload(); - return uploadAttachmentsToStorage(fileParts, - uploadConfig.getAttachment() - ); - }); + return validateUploadRequest(fileParts) + .flatMap(editorConfig -> validateUploadPermission(editorConfig) + .then(Mono.just(editorConfig)) + ) + .flatMap(editorConfig -> { + var uploadConfig = editorConfig.getUpload(); + return uploadAttachmentsToStorage(fileParts, + uploadConfig.getAttachment() + ); + }); } - + private Mono validateUploadRequest( List fileParts) { if (fileParts.isEmpty()) { return Mono.error( new ServerWebInputException("At least one file is required")); } - + return settingConfigGetter.getEditorConfig().flatMap(editorConfig -> { if (!editorConfig.isEnableUpload()) { return Mono.error( @@ -120,18 +122,18 @@ private Mono validateUploadRequest( return Mono.just(editorConfig); }); } - + /** * Validate upload permission (anonymous user permission check). */ private Mono validateUploadPermission( SettingConfigGetter.EditorConfig editorConfig) { var uploadConfig = editorConfig.getUpload(); - + if (uploadConfig.isAllowAnonymous()) { return Mono.empty(); } - + // Anonymous upload is not allowed, check if the current user is an anonymous user return isAnonymousCommenter().flatMap(isAnonymous -> { if (isAnonymous) { @@ -143,7 +145,7 @@ private Mono validateUploadPermission( return Mono.empty(); }); } - + /** * Upload attachments to storage service. If any attachment upload fails, all successfully uploaded attachments will be rolled back and deleted. */ @@ -159,7 +161,7 @@ private Mono> uploadAttachmentsToStorage( )); } List uploadedAttachments = new CopyOnWriteArrayList<>(); - + return authenticationConsumerNullable( authentication -> Flux.fromIterable(fileParts) // Ensure sequential upload @@ -177,7 +179,7 @@ private Mono> uploadAttachmentsToStorage( .onErrorResume(error -> rollbackUploadedAttachments( uploadedAttachments).then(Mono.error(error)))); } - + /** * Rollback and delete uploaded attachments. */ @@ -186,7 +188,7 @@ private Mono rollbackUploadedAttachments( if (attachments.isEmpty()) { return Mono.empty(); } - + return Flux.fromIterable(attachments).flatMap(attachment -> { String attachmentName = attachment.getMetadata().getName(); return attachmentService.delete(attachment) @@ -200,7 +202,7 @@ private Mono rollbackUploadedAttachments( }); }).then(); } - + /** * Set the permanent link of the attachment. */ @@ -215,7 +217,7 @@ private Mono setPermalinkToAttachment(Attachment attachment) { status.setPermalink(permalink.toString()); }).thenReturn(attachment); } - + private RateLimiterOperator createIpBasedRateLimiter( ServerRequest request) { var clientIp = IpAddressUtils.getClientIp(request); @@ -240,39 +242,42 @@ private RateLimiterOperator createIpBasedRateLimiter( } return RateLimiterOperator.of(rateLimiter); } - + Mono authenticationConsumerNullable( Function> func) { return ReactiveSecurityContextHolder.getContext().map( SecurityContext::getAuthentication).flatMap(func); } - + Mono isAnonymousCommenter() { return ReactiveSecurityContextHolder.getContext().map( - context -> AnonymousUserConst.isAnonymousUser( - context.getAuthentication().getName())).defaultIfEmpty(true); + context -> AnonymousUserConst.isAnonymousUser( + context.getAuthentication().getName()) + ) + .defaultIfEmpty(true); } - + @Schema(types = "object") public interface IUploadRequest { - + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Attachment files, support multiple files") List getFiles(); } - + public record UploadRequest(MultiValueMap formData) implements IUploadRequest { - + @Override public List getFiles() { List parts = formData.get("files"); if (CollectionUtils.isEmpty(parts)) { throw new ServerWebInputException("No files found"); } - - return parts.stream().filter(part -> part instanceof FilePart).map( - part -> (FilePart) part).collect(Collectors.toList()); + + return parts.stream().filter(part -> part instanceof FilePart) + .map(part -> (FilePart) part) + .collect(Collectors.toList()); } } - + } From a1b1d58b6b4b9b9b66aabd2a1a4a8066eb541165 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Thu, 8 Jan 2026 12:03:48 +0800 Subject: [PATCH 3/3] limit the number of uploads per session --- .../java/run/halo/comment/widget/UploadMediaEndpoint.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java index 1706446..4079bcd 100644 --- a/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java +++ b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java @@ -274,6 +274,10 @@ public List getFiles() { throw new ServerWebInputException("No files found"); } + if (parts.size() > 10) { + throw new ServerWebInputException("Maximum of 10 files allowed"); + } + return parts.stream().filter(part -> part instanceof FilePart) .map(part -> (FilePart) part) .collect(Collectors.toList());