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..b77ea20 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: 50, + minHeight: 50, + 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..7dba8c2 --- /dev/null +++ b/packages/comment-widget/src/extension/editor-upload.ts @@ -0,0 +1,367 @@ +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) { + 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; + } 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/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/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..0b77371 --- /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..d078fb7 --- /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..34b5f77 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java @@ -24,13 +24,36 @@ public interface SettingConfigGetter { */ 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(); + private CaptchaConfig captcha + = CaptchaConfig.empty(); public SecurityConfig setCaptcha(CaptchaConfig captcha) { this.captcha = (captcha == null ? CaptchaConfig.empty() : captcha); @@ -38,8 +61,7 @@ public SecurityConfig setCaptcha(CaptchaConfig captcha) { } public static SecurityConfig empty() { - return new SecurityConfig() - .setCaptcha(CaptchaConfig.empty()); + return new SecurityConfig().setCaptcha(CaptchaConfig.empty()); } } @@ -50,7 +72,8 @@ class CaptchaConfig { private boolean anonymousCommentCaptcha; @Getter(onMethod_ = @NonNull) - private CaptchaType type = CaptchaType.ALPHANUMERIC; + private CaptchaType type + = CaptchaType.ALPHANUMERIC; private boolean ignoreCase = true; diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java index 6b4fad3..2d4f475 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java @@ -27,4 +27,10 @@ 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..4079bcd --- /dev/null +++ b/src/main/java/run/halo/comment/widget/UploadMediaEndpoint.java @@ -0,0 +1,287 @@ +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"); + } + + 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()); + } + } + +} 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: 存储组