From 94f7186cce882b29c1381bee38f223f3979e42d2 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 5 Apr 2026 22:10:05 +0800 Subject: [PATCH] feat: add image upload functionality for articles - Implemented image upload API in the article service, allowing users to upload images associated with articles. - Added validation for image size and type, ensuring only supported formats (PNG, JPG, GIF, WebP) are accepted. - Created a new Aliyun object storage client to handle image storage and retrieval. - Updated the article handler to include an endpoint for image uploads. - Enhanced error handling for image upload failures and added relevant error messages. - Introduced public URL generation for stored images, allowing access via signed tokens. - Updated localization files to include new messages related to image upload functionality. - Added tests for image upload and retrieval processes. --- .../src/components/ArticleEditorCanvas.vue | 366 +++++++++++++++++- apps/admin-web/src/i18n/messages/en-US.ts | 4 + apps/admin-web/src/i18n/messages/zh-CN.ts | 4 + apps/admin-web/src/lib/api.ts | 13 + apps/admin-web/src/lib/errors.ts | 7 + .../admin-web/src/views/ArticleEditorView.vue | 6 + packages/shared-types/src/index.ts | 7 + server/configs/config.local.yaml.example | 17 + server/go.mod | 2 + server/go.sum | 4 + server/internal/shared/config/config.go | 22 ++ .../internal/shared/objectstorage/aliyun.go | 213 ++++++++++ .../internal/shared/objectstorage/client.go | 7 + server/internal/shared/objectstorage/minio.go | 9 + server/internal/shared/objectstorage/url.go | 116 ++++++ server/internal/tenant/app/article_service.go | 123 +++++- .../tenant/transport/article_handler.go | 41 +- .../tenant/transport/asset_handler.go | 82 ++++ server/internal/tenant/transport/router.go | 4 + 19 files changed, 1033 insertions(+), 14 deletions(-) create mode 100644 server/internal/shared/objectstorage/aliyun.go create mode 100644 server/internal/shared/objectstorage/url.go create mode 100644 server/internal/tenant/transport/asset_handler.go diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index 8f9e7ab..951c7fe 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -12,6 +12,7 @@ import { UndoOutlined, UnorderedListOutlined, } from "@ant-design/icons-vue"; +import { message } from "ant-design-vue"; import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history"; import { insertHrCommand, @@ -30,13 +31,16 @@ import { Crepe, CrepeFeature } from "@milkdown/crepe"; import "@milkdown/crepe/theme/common/style.css"; import "@milkdown/crepe/theme/frame.css"; import { Milkdown, useEditor, useInstance } from "@milkdown/vue"; -import { computed, onBeforeUnmount, ref, watch } from "vue"; +import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; +import { formatError } from "@/lib/errors"; + const props = defineProps<{ title: string; modelValue: string; disabled?: boolean; + uploadImage?: (file: File) => Promise; }>(); const emit = defineEmits<{ @@ -47,6 +51,17 @@ const emit = defineEmits<{ const { t } = useI18n(); const crepeRef = ref(null); const syncingExternalMarkdown = ref(false); +const tablePickerRef = ref(null); +const tableGridRef = ref(null); +const imageInputRef = ref(null); + +const TABLE_PICKER_CELL_SIZE = 22; +const TABLE_PICKER_GAP = 6; +const TABLE_PICKER_INITIAL_ROWS = 4; +const TABLE_PICKER_INITIAL_COLS = 10; +const TABLE_PICKER_MAX_ROWS = 15; +const TABLE_PICKER_MAX_COLS = 15; +const TABLE_PICKER_EXPAND_STEP = 2; const { loading } = useEditor((root) => { const crepe = new Crepe({ @@ -78,6 +93,37 @@ const { loading } = useEditor((root) => { const [instanceLoading, getEditor] = useInstance(); const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value); +const tablePickerOpen = ref(false); +const tablePickerRows = ref(TABLE_PICKER_INITIAL_ROWS); +const tablePickerCols = ref(TABLE_PICKER_INITIAL_COLS); +const tablePreviewRow = ref(0); +const tablePreviewCol = ref(0); +const tablePointerActive = ref(false); +const imageUploading = ref(false); + +const tablePickerGridStyle = computed(() => ({ + gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`, +})); + +const tablePickerCells = computed(() => { + const cells: Array<{ key: string; row: number; col: number }> = []; + + for (let row = 1; row <= tablePickerRows.value; row += 1) { + for (let col = 1; col <= tablePickerCols.value; col += 1) { + cells.push({ key: `${row}-${col}`, row, col }); + } + } + + return cells; +}); + +const tableSelectionLabel = computed(() => { + if (!tablePreviewRow.value || !tablePreviewCol.value) { + return t("article.editor.tablePicker.empty"); + } + + return `${tablePreviewRow.value} × ${tablePreviewCol.value}`; +}); function syncMarkdownFromProps(nextValue: string): void { const editor = getEditor(); @@ -102,6 +148,9 @@ watch( () => props.disabled, (value) => { crepeRef.value?.setReadonly(!!value); + if (value) { + closeTablePicker(); + } }, { immediate: true }, ); @@ -119,9 +168,18 @@ watch( ); onBeforeUnmount(() => { + window.removeEventListener("pointerdown", handleWindowPointerDown); + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); crepeRef.value = null; }); +onMounted(() => { + window.addEventListener("pointerdown", handleWindowPointerDown); + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); +}); + function runCommand(command: { key: unknown }, payload?: unknown): void { if (editorDisabled.value) { return; @@ -140,6 +198,11 @@ function insertImage(): void { return; } + if (props.uploadImage) { + imageInputRef.value?.click(); + return; + } + const src = window.prompt(t("article.editor.imagePrompt")); if (!src?.trim()) { return; @@ -148,8 +211,167 @@ function insertImage(): void { runCommand(insertImageCommand, { src: src.trim() }); } -function insertTable(): void { - runCommand(insertTableCommand, { row: 3, col: 3 }); +async function handleImageFileChange(event: Event): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ""; + + if (!file || !props.uploadImage || editorDisabled.value || imageUploading.value) { + return; + } + + imageUploading.value = true; + try { + const src = (await props.uploadImage(file)).trim(); + if (!src) { + throw new Error("article_image_url_unavailable"); + } + + runCommand(insertImageCommand, { + src, + alt: resolveImageAltText(file.name), + }); + } catch (error) { + message.error(formatError(error)); + } finally { + imageUploading.value = false; + } +} + +function resolveImageAltText(fileName: string): string { + return fileName.replace(/\.[^.]+$/, "").trim() || "image"; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function resetTablePicker(): void { + tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS; + tablePickerCols.value = TABLE_PICKER_INITIAL_COLS; + tablePreviewRow.value = 0; + tablePreviewCol.value = 0; + tablePointerActive.value = false; +} + +function closeTablePicker(): void { + tablePickerOpen.value = false; + resetTablePicker(); +} + +function ensureTablePickerCapacity(row: number, col: number): void { + while (row > tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) { + tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP); + } + + while (col > tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) { + tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP); + } + + if (row === tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) { + tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP); + } + + if (col === tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) { + tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP); + } +} + +function updateTablePreview(row: number, col: number): void { + const nextRow = clamp(row, 1, TABLE_PICKER_MAX_ROWS); + const nextCol = clamp(col, 1, TABLE_PICKER_MAX_COLS); + + ensureTablePickerCapacity(nextRow, nextCol); + + tablePreviewRow.value = nextRow; + tablePreviewCol.value = nextCol; +} + +function resolveTableSizeFromPointer(clientX: number, clientY: number): { row: number; col: number } | null { + const grid = tableGridRef.value; + if (!grid) { + return null; + } + + const rect = grid.getBoundingClientRect(); + const stepX = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP; + const stepY = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP; + const relativeX = clientX - rect.left; + const relativeY = clientY - rect.top; + + return { + row: clamp(Math.ceil((relativeY + TABLE_PICKER_GAP) / stepY), 1, TABLE_PICKER_MAX_ROWS), + col: clamp(Math.ceil((relativeX + TABLE_PICKER_GAP) / stepX), 1, TABLE_PICKER_MAX_COLS), + }; +} + +function insertTableFromSelection(row: number, col: number): void { + runCommand(insertTableCommand, { row, col }); + closeTablePicker(); +} + +function toggleTablePicker(): void { + if (editorDisabled.value) { + return; + } + + if (tablePickerOpen.value) { + closeTablePicker(); + return; + } + + resetTablePicker(); + tablePickerOpen.value = true; +} + +function beginTableSelection(row: number, col: number): void { + if (editorDisabled.value) { + return; + } + + tablePointerActive.value = true; + updateTablePreview(row, col); +} + +function handleWindowPointerDown(event: PointerEvent): void { + if (!tablePickerOpen.value) { + return; + } + + const target = event.target; + if (target instanceof Node && tablePickerRef.value?.contains(target)) { + return; + } + + closeTablePicker(); +} + +function handleWindowPointerMove(event: PointerEvent): void { + if (!tablePickerOpen.value || !tablePointerActive.value) { + return; + } + + const nextSize = resolveTableSizeFromPointer(event.clientX, event.clientY); + if (!nextSize) { + return; + } + + updateTablePreview(nextSize.row, nextSize.col); +} + +function handleWindowPointerUp(): void { + if (!tablePointerActive.value) { + return; + } + + tablePointerActive.value = false; + + if (!tablePreviewRow.value || !tablePreviewCol.value) { + closeTablePicker(); + return; + } + + insertTableFromSelection(tablePreviewRow.value, tablePreviewCol.value); } @@ -190,12 +412,58 @@ function insertTable(): void { - - - - + +
+ + + + +
+
{{ tableSelectionLabel }}
+
+ +
+
{{ t("article.editor.tablePicker.hint") }}
+
+
+ - + + @@ -241,6 +509,8 @@ function insertTable(): void { } .article-editor-canvas__toolbar { + position: relative; + z-index: 3; display: flex; flex-wrap: wrap; align-items: center; @@ -258,8 +528,86 @@ function insertTable(): void { background: #e5ebf5; } +.article-editor-canvas__table-trigger { + position: relative; + display: flex; + align-items: center; +} + +.article-editor-canvas__toolbar :deep(.article-editor-canvas__toolbar-button--active) { + color: #245bdb; + background: rgba(47, 107, 255, 0.1); +} + +.article-editor-canvas__table-panel { + position: absolute; + top: calc(100% + 12px); + left: -18px; + z-index: 24; + width: max-content; + max-width: min(calc(100vw - 48px), 520px); + padding: 18px; + border: 1px solid #e3e9f4; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 255, 0.98) 100%); + box-shadow: + 0 18px 40px rgba(15, 23, 42, 0.14), + 0 2px 8px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(16px); +} + +.article-editor-canvas__table-panel-status { + margin-bottom: 14px; + color: #101828; + font-size: 14px; + font-weight: 700; + text-align: center; +} + +.article-editor-canvas__table-grid { + display: grid; + gap: 6px; + justify-content: start; +} + +.article-editor-canvas__table-cell { + width: 22px; + height: 22px; + padding: 0; + border: 1px solid #d5dbe7; + border-radius: 4px; + background: #ffffff; + cursor: crosshair; + transition: + border-color 0.14s ease, + background-color 0.14s ease, + transform 0.14s ease, + box-shadow 0.14s ease; +} + +.article-editor-canvas__table-cell:hover, +.article-editor-canvas__table-cell--active { + border-color: #2f6bff; + background: rgba(47, 107, 255, 0.14); + box-shadow: inset 0 0 0 1px rgba(47, 107, 255, 0.08); +} + +.article-editor-canvas__table-cell:focus-visible { + outline: 2px solid rgba(47, 107, 255, 0.36); + outline-offset: 2px; +} + +.article-editor-canvas__table-panel-hint { + margin-top: 12px; + color: #667085; + font-size: 12px; + line-height: 1.4; +} + .article-editor-canvas__surface { position: relative; + z-index: 1; flex: 1; min-height: 0; overflow-y: auto; @@ -267,7 +615,7 @@ function insertTable(): void { .article-editor-canvas__title-row { position: relative; - z-index: 1; + z-index: 0; padding: 18px 28px 0; } diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index c8609ca..01e2119 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -406,6 +406,10 @@ const enUS = { coverHint: "Upload a cover asset before publishing. This is local preview only for now.", coverUpload: "Upload cover image", imagePrompt: "Enter image URL", + tablePicker: { + empty: "Drag to choose a table size", + hint: "Keep dragging near the edge to expand the grid", + }, messages: { saved: "Article saved.", generating: "This article is still generating. Try editing it later.", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 5c14300..183b314 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -413,6 +413,10 @@ const zhCN = { coverHint: "发布前可先上传封面素材,当前只做本地预览。", coverUpload: "上传封面图", imagePrompt: "请输入图片地址", + tablePicker: { + empty: "拖拽选择表格尺寸", + hint: "按住拖动到边缘可继续扩展", + }, messages: { saved: "文章已保存", generating: "文章还在生成中,请稍后再编辑。", diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 4cb774a..4e67253 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -1,6 +1,8 @@ import { createApiClient } from "@geo/http-client"; import type { + ApiEnvelope, ArticleDetail, + ArticleImageUploadResponse, ArticleListParams, ArticleListResponse, ArticleVersion, @@ -214,6 +216,17 @@ export const articlesApi = { update(id: number, payload: UpdateArticleRequest) { return apiClient.put(`/api/tenant/articles/${id}`, payload); }, + async uploadImage(id: number, file: File) { + const formData = new FormData(); + formData.append("file", file); + + const response = await apiClient.raw.post>( + `/api/tenant/articles/${id}/images`, + formData, + ); + + return response.data.data; + }, versions(id: number) { return apiClient.get(`/api/tenant/articles/${id}/versions`); }, diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 10c1291..70832ee 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -24,8 +24,15 @@ const errorMessageMap: Record = { outline_task_not_found: "文章大纲任务不存在或已失效", article_generating: "文章仍在生成中", article_not_editable: "只有已完成文章才可编辑", + article_lookup_failed: "文章状态读取失败", draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑", article_title_required: "请输入文章标题", + invalid_image: "请选择图片文件", + article_image_too_large: "图片不能超过 10MB", + article_image_type_not_supported: "仅支持 PNG、JPG、GIF、WebP 图片", + article_image_upload_failed: "图片上传失败", + article_image_url_unavailable: "图片地址生成失败", + object_storage_unavailable: "对象存储未配置或当前不可用", invalid_payload: "请求参数不合法", update_failed: "保存文章失败", publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试", diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index d7dad86..780bae1 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -138,6 +138,11 @@ async function handlePublish(): Promise { publishModalOpen.value = true; } +async function uploadEditorImage(file: File): Promise { + const result = await articlesApi.uploadImage(articleId.value, file); + return result.url; +} + async function handlePublished(): Promise { await Promise.all([ detailQuery.refetch(), @@ -273,6 +278,7 @@ function serializePlatformSelection(platformIds: string[]): string { v-model:title="title" v-model="markdown" :disabled="editorLocked" + :upload-image="uploadEditorImage" /> diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d2260ba..3d80b47 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -273,6 +273,13 @@ export interface UpdateArticleRequest { platforms?: string[]; } +export interface ArticleImageUploadResponse { + url: string; + object_key: string; + content_type: string; + size_bytes: number; +} + export interface ArticleVersion { id: number; version_no: number; diff --git a/server/configs/config.local.yaml.example b/server/configs/config.local.yaml.example index fdca851..3722f7a 100644 --- a/server/configs/config.local.yaml.example +++ b/server/configs/config.local.yaml.example @@ -25,9 +25,26 @@ object_storage: endpoint: localhost:9000 bucket: geo-private use_ssl: false + public_base_url: "" + region: "" # Prefer environment override for secrets: # export OBJECT_STORAGE_ACCESS_KEY=minioadmin # export OBJECT_STORAGE_SECRET_KEY=minioadmin + # MinIO example: + # provider: minio + # endpoint: localhost:9000 + # bucket: geo-private + # use_ssl: false + # + # Aliyun OSS example: + # provider: aliyun + # endpoint: https://oss-cn-hangzhou.aliyuncs.com + # bucket: your-bucket + # region: cn-hangzhou + # use_ssl: true + # public_base_url: https://your-bucket.oss-cn-hangzhou.aliyuncs.com + # export OBJECT_STORAGE_ACCESS_KEY=your-access-key + # export OBJECT_STORAGE_SECRET_KEY=your-secret-key jwt: secret: "your-local-secret" diff --git a/server/go.mod b/server/go.mod index a5baccb..4e9307f 100644 --- a/server/go.mod +++ b/server/go.mod @@ -22,6 +22,7 @@ require ( ) require ( + github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect @@ -78,6 +79,7 @@ require ( golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect google.golang.org/grpc v1.66.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/server/go.sum b/server/go.sum index 5d2907f..ade75d9 100644 --- a/server/go.sum +++ b/server/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= +github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= @@ -234,6 +236,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 8668115..e089e17 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -181,6 +181,12 @@ func applyEnvOverrides(cfg *Config) { if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok { cfg.ObjectStorage.PublicBaseURL = publicBaseURL } + if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok { + cfg.ObjectStorage.Region = region + } + if useSSL, ok := lookupBoolEnv("OBJECT_STORAGE_USE_SSL"); ok { + cfg.ObjectStorage.UseSSL = useSSL + } if apiKey, ok := lookupFirstNonEmptyEnv("SILICONFLOW_API_KEY", "RETRIEVAL_API_KEY", "EMBEDDING_API_KEY", "RERANKER_API_KEY"); ok { cfg.Retrieval.APIKey = apiKey } @@ -217,3 +223,19 @@ func lookupNonEmptyEnv(key string) (string, bool) { return value, true } + +func lookupBoolEnv(key string) (bool, bool) { + value, ok := os.LookupEnv(key) + if !ok { + return false, false + } + + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true, true + case "0", "false", "no", "off": + return false, true + default: + return false, false + } +} diff --git a/server/internal/shared/objectstorage/aliyun.go b/server/internal/shared/objectstorage/aliyun.go new file mode 100644 index 0000000..84d220d --- /dev/null +++ b/server/internal/shared/objectstorage/aliyun.go @@ -0,0 +1,213 @@ +package objectstorage + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/shared/config" + "github.com/geo-platform/tenant-api/internal/shared/middleware" +) + +type aliyunClient struct { + client *oss.Client + bucket string + logger *zap.Logger + cfg config.ObjectStorageConfig +} + +func NewAliyunClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client { + endpoint := strings.TrimSpace(cfg.Endpoint) + accessKey := strings.TrimSpace(cfg.AccessKey) + secretKey := strings.TrimSpace(cfg.SecretKey) + bucket := strings.TrimSpace(cfg.Bucket) + region := strings.TrimSpace(cfg.Region) + if endpoint == "" || accessKey == "" || secretKey == "" || bucket == "" || region == "" { + return disabledClient{reason: "missing object_storage aliyun endpoint/access_key/secret_key/bucket/region"} + } + + endpointURL, err := parseURLWithScheme(endpoint, boolScheme(cfg.UseSSL)) + if err != nil { + return disabledClient{reason: fmt.Sprintf("parse aliyun endpoint failed: %v", err)} + } + + client, err := oss.New( + endpointURL.String(), + accessKey, + secretKey, + oss.AuthVersion(oss.AuthV4), + oss.Region(region), + ) + if err != nil { + return disabledClient{reason: fmt.Sprintf("init aliyun oss client failed: %v", err)} + } + + return &aliyunClient{ + client: client, + bucket: bucket, + logger: logger, + cfg: cfg, + } +} + +func (c *aliyunClient) Validate() error { + if c == nil || c.client == nil { + return fmt.Errorf("%w: aliyun oss client is not initialized", ErrNotConfigured) + } + if strings.TrimSpace(c.bucket) == "" { + return fmt.Errorf("%w: aliyun bucket is empty", ErrNotConfigured) + } + if strings.TrimSpace(c.cfg.Region) == "" { + return fmt.Errorf("%w: aliyun region is empty", ErrNotConfigured) + } + return nil +} + +func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error { + if err := c.Validate(); err != nil { + return err + } + if strings.TrimSpace(objectKey) == "" { + return fmt.Errorf("object key is required") + } + + exists, err := c.client.IsBucketExist(c.bucket) + if err != nil { + c.logError(ctx, "aliyun bucket exists check failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return fmt.Errorf("check aliyun bucket: %w", err) + } + if !exists { + if err := c.client.CreateBucket(c.bucket); err != nil { + c.logError(ctx, "aliyun create bucket failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return fmt.Errorf("create aliyun bucket: %w", err) + } + } + + bucket, err := c.client.Bucket(c.bucket) + if err != nil { + c.logError(ctx, "aliyun get bucket failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return fmt.Errorf("get aliyun bucket: %w", err) + } + + if err := bucket.PutObject(objectKey, bytes.NewReader(content), oss.ContentType(contentType)); err != nil { + c.logError(ctx, "aliyun put object failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Int("content_size", len(content)), + zap.Error(err), + ) + return fmt.Errorf("put aliyun object: %w", err) + } + + return nil +} + +func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) { + if err := c.Validate(); err != nil { + return nil, err + } + + bucket, err := c.client.Bucket(c.bucket) + if err != nil { + c.logError(ctx, "aliyun get bucket failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return nil, fmt.Errorf("get aliyun bucket: %w", err) + } + + reader, err := bucket.GetObject(objectKey) + if err != nil { + c.logError(ctx, "aliyun get object failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return nil, fmt.Errorf("get aliyun object: %w", err) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + c.logError(ctx, "aliyun read object failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return nil, fmt.Errorf("read aliyun object: %w", err) + } + + return data, nil +} + +func (c *aliyunClient) Delete(ctx context.Context, objectKey string) error { + if err := c.Validate(); err != nil { + return err + } + if strings.TrimSpace(objectKey) == "" { + return nil + } + + bucket, err := c.client.Bucket(c.bucket) + if err != nil { + c.logError(ctx, "aliyun get bucket failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return fmt.Errorf("get aliyun bucket: %w", err) + } + + if err := bucket.DeleteObject(objectKey); err != nil { + c.logError(ctx, "aliyun delete object failed", + zap.String("bucket", c.bucket), + zap.String("object_key", objectKey), + zap.Error(err), + ) + return fmt.Errorf("delete aliyun object: %w", err) + } + + return nil +} + +func (c *aliyunClient) PublicURL(objectKey string) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + return buildPublicURL(c.cfg, objectKey, true) +} + +func (c *aliyunClient) logError(ctx context.Context, msg string, fields ...zap.Field) { + if c == nil || c.logger == nil { + return + } + if requestID := middleware.RequestIDFromContext(ctx); requestID != "" { + fields = append(fields, zap.String("request_id", requestID)) + } + c.logger.Error(msg, fields...) +} + +func boolScheme(useSSL bool) string { + if useSSL { + return "https" + } + return "http" +} diff --git a/server/internal/shared/objectstorage/client.go b/server/internal/shared/objectstorage/client.go index 301e373..b055b35 100644 --- a/server/internal/shared/objectstorage/client.go +++ b/server/internal/shared/objectstorage/client.go @@ -18,6 +18,7 @@ type Client interface { PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error GetBytes(ctx context.Context, objectKey string) ([]byte, error) Delete(ctx context.Context, objectKey string) error + PublicURL(objectKey string) (string, error) } func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client { @@ -27,6 +28,8 @@ func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client { return disabledClient{reason: "object storage is disabled"} case "minio": return NewMinIOClient(cfg, logger) + case "aliyun", "aliyun_oss", "aliyun-oss", "oss": + return NewAliyunClient(cfg, logger) default: return disabledClient{reason: fmt.Sprintf("unsupported object storage provider %q", cfg.Provider)} } @@ -54,3 +57,7 @@ func (c disabledClient) GetBytes(context.Context, string) ([]byte, error) { func (c disabledClient) Delete(context.Context, string) error { return c.Validate() } + +func (c disabledClient) PublicURL(string) (string, error) { + return "", c.Validate() +} diff --git a/server/internal/shared/objectstorage/minio.go b/server/internal/shared/objectstorage/minio.go index 56e85e0..5ccab07 100644 --- a/server/internal/shared/objectstorage/minio.go +++ b/server/internal/shared/objectstorage/minio.go @@ -20,6 +20,7 @@ type minioClient struct { bucket string region string logger *zap.Logger + cfg config.ObjectStorageConfig } func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client { @@ -45,6 +46,7 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client { bucket: bucket, region: strings.TrimSpace(cfg.Region), logger: logger, + cfg: cfg, } } @@ -153,6 +155,13 @@ func (c *minioClient) Delete(ctx context.Context, objectKey string) error { return nil } +func (c *minioClient) PublicURL(objectKey string) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + return buildPublicURL(c.cfg, objectKey, false) +} + func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) { if c == nil || c.logger == nil { return diff --git a/server/internal/shared/objectstorage/url.go b/server/internal/shared/objectstorage/url.go new file mode 100644 index 0000000..f2dc87d --- /dev/null +++ b/server/internal/shared/objectstorage/url.go @@ -0,0 +1,116 @@ +package objectstorage + +import ( + "fmt" + "net/url" + "strings" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +func buildPublicURL(cfg config.ObjectStorageConfig, objectKey string, bucketInHost bool) (string, error) { + objectKey = strings.TrimSpace(objectKey) + if objectKey == "" { + return "", fmt.Errorf("object key is required") + } + + defaultScheme := "http" + if cfg.UseSSL { + defaultScheme = "https" + } + + if publicBaseURL := strings.TrimSpace(cfg.PublicBaseURL); publicBaseURL != "" { + baseURL, err := parseURLWithScheme(publicBaseURL, defaultScheme) + if err != nil { + return "", fmt.Errorf("parse public_base_url: %w", err) + } + baseURL.Path = joinEscapedPath(baseURL.Path, objectKey) + baseURL.RawPath = "" + return baseURL.String(), nil + } + + endpoint := strings.TrimSpace(cfg.Endpoint) + bucket := strings.TrimSpace(cfg.Bucket) + if endpoint == "" || bucket == "" { + return "", fmt.Errorf("%w: object storage endpoint or bucket is missing", ErrNotConfigured) + } + + endpointURL, err := parseURLWithScheme(endpoint, defaultScheme) + if err != nil { + return "", fmt.Errorf("parse object storage endpoint: %w", err) + } + + if bucketInHost { + hostname := endpointURL.Hostname() + if hostname == "" { + return "", fmt.Errorf("invalid object storage endpoint host") + } + + host := hostname + if !strings.HasPrefix(host, bucket+".") { + host = bucket + "." + host + } + if port := endpointURL.Port(); port != "" { + host += ":" + port + } + + endpointURL.Host = host + endpointURL.Path = joinEscapedPath("", objectKey) + endpointURL.RawPath = "" + return endpointURL.String(), nil + } + + endpointURL.Path = joinEscapedPath(endpointURL.Path, bucket, objectKey) + endpointURL.RawPath = "" + return endpointURL.String(), nil +} + +func parseURLWithScheme(raw string, defaultScheme string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("url is empty") + } + + if !strings.Contains(raw, "://") { + raw = defaultScheme + "://" + raw + } + + parsed, err := url.Parse(raw) + if err != nil { + return nil, err + } + if parsed.Host == "" { + return nil, fmt.Errorf("url host is empty") + } + return parsed, nil +} + +func joinEscapedPath(basePath string, extra ...string) string { + segments := make([]string, 0) + + appendPath := func(value string) { + value = strings.TrimSpace(value) + value = strings.Trim(value, "/") + if value == "" { + return + } + + for _, segment := range strings.Split(value, "/") { + segment = strings.TrimSpace(segment) + if segment == "" { + continue + } + segments = append(segments, url.PathEscape(segment)) + } + } + + appendPath(basePath) + for _, value := range extra { + appendPath(value) + } + + if len(segments) == 0 { + return "/" + } + return "/" + strings.Join(segments, "/") +} diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index e73a7c0..48c8598 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -3,26 +3,43 @@ package app import ( "context" "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" "strconv" "strings" "time" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/contentstats" "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/response" ) type ArticleService struct { - pool *pgxpool.Pool - auditLogs *auditlog.AsyncWriter + pool *pgxpool.Pool + auditLogs *auditlog.AsyncWriter + objectStorage objectstorage.Client } -func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ArticleService { - return &ArticleService{pool: pool, auditLogs: auditLogs} +func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService { + return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage} +} + +const maxArticleImageSizeBytes = 10 * 1024 * 1024 + +var supportedArticleImageExtensions = map[string]string{ + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", } type ArticleListParams struct { @@ -303,6 +320,13 @@ type UpdateArticleRequest struct { Platforms []string `json:"platforms"` } +type ArticleImageUploadResponse struct { + URL string `json:"url"` + ObjectKey string `json:"object_key"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` +} + func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) { actor := auth.MustActor(ctx) @@ -383,6 +407,54 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle return s.Detail(ctx, id) } +func (s *ArticleService) UploadImage( + ctx context.Context, + articleID int64, + fileName string, + content []byte, +) (*ArticleImageUploadResponse, error) { + actor := auth.MustActor(ctx) + + if err := s.objectStorage.Validate(); err != nil { + return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + if len(content) == 0 { + return nil, response.ErrBadRequest(40016, "invalid_image", "image file is required") + } + if len(content) > maxArticleImageSizeBytes { + return nil, response.ErrBadRequest(40017, "article_image_too_large", "image size cannot exceed 10 MB") + } + + contentType := http.DetectContentType(content) + resolvedExt, ok := supportedArticleImageExtensions[contentType] + if !ok { + return nil, response.ErrBadRequest(40018, "article_image_type_not_supported", "only PNG, JPG, GIF, and WebP images are supported") + } + + generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID) + if err != nil { + return nil, err + } + if generateStatus != "completed" { + return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited") + } + + objectKey := buildArticleImageObjectKey(actor.TenantID, articleID, fileName, resolvedExt) + if err := s.objectStorage.PutBytes(ctx, objectKey, content, contentType); err != nil { + if errors.Is(err, objectstorage.ErrNotConfigured) { + return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured") + } + return nil, response.ErrInternal(50013, "article_image_upload_failed", "failed to upload article image") + } + + return &ArticleImageUploadResponse{ + URL: "", + ObjectKey: objectKey, + ContentType: contentType, + SizeBytes: int64(len(content)), + }, nil +} + type VersionItem struct { ID int64 `json:"id"` VersionNo int `json:"version_no"` @@ -466,6 +538,49 @@ func countArticleWords(input string) int { return contentstats.CountWords(input) } +func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) { + var generateStatus string + if err := s.pool.QueryRow(ctx, ` + SELECT generate_status + FROM articles + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, articleID, tenantID).Scan(&generateStatus); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", response.ErrNotFound(40411, "article_not_found", "article not found") + } + return "", response.ErrInternal(50013, "article_lookup_failed", "failed to query article state") + } + return generateStatus, nil +} + +func buildArticleImageObjectKey(tenantID int64, articleID int64, fileName string, fallbackExt string) string { + now := time.Now().UTC() + extension := normalizeArticleImageExtension(fileName, fallbackExt) + return fmt.Sprintf( + "tenants/%d/articles/%d/images/%04d/%02d/%02d/%s.%s", + tenantID, + articleID, + now.Year(), + int(now.Month()), + now.Day(), + uuid.NewString(), + extension, + ) +} + +func normalizeArticleImageExtension(fileName string, fallbackExt string) string { + extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))), ".") + if extension != "" { + switch extension { + case "jpg", "jpeg": + return "jpg" + case "png", "gif", "webp": + return extension + } + } + return fallbackExt +} + func resolveWordCount(markdown *string, stored int) int { if markdown != nil { if counted := countArticleWords(*markdown); counted > 0 { diff --git a/server/internal/tenant/transport/article_handler.go b/server/internal/tenant/transport/article_handler.go index 7b97d7d..1a1f27f 100644 --- a/server/internal/tenant/transport/article_handler.go +++ b/server/internal/tenant/transport/article_handler.go @@ -2,6 +2,7 @@ package transport import ( "encoding/json" + "io" "net/http" "strconv" "time" @@ -19,6 +20,7 @@ type ArticleHandler struct { promptGenerateSvc *app.PromptRuleGenerationService streams *stream.GenerationHub streamEnabled bool + assets *AssetHandler } func NewArticleHandler(a *bootstrap.App) *ArticleHandler { @@ -36,7 +38,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler { ) return &ArticleHandler{ - svc: app.NewArticleService(a.DB, a.AuditLogs), + svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage), promptGenerateSvc: app.NewPromptRuleGenerationService( a.DB, a.LLM, @@ -47,6 +49,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler { ), streams: a.GenerationStreams, streamEnabled: a.Config.Generation.StreamEnabled, + assets: NewAssetHandler(a), } } @@ -171,6 +174,42 @@ func (h *ArticleHandler) Update(c *gin.Context) { response.Success(c, data) } +func (h *ArticleHandler) UploadImage(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) + return + } + + fileHeader, err := c.FormFile("file") + if err != nil { + response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file is required")) + return + } + + file, err := fileHeader.Open() + if err != nil { + response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be opened")) + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be read")) + return + } + + data, err := h.svc.UploadImage(c.Request.Context(), id, fileHeader.Filename, content) + if err != nil { + response.Error(c, err) + return + } + data.URL = h.assets.BuildArticleImageURL(data.ObjectKey) + + response.SuccessWithStatus(c, http.StatusCreated, data) +} + func (h *ArticleHandler) Stream(c *gin.Context) { if !h.streamEnabled { response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled")) diff --git a/server/internal/tenant/transport/asset_handler.go b/server/internal/tenant/transport/asset_handler.go new file mode 100644 index 0000000..725a214 --- /dev/null +++ b/server/internal/tenant/transport/asset_handler.go @@ -0,0 +1,82 @@ +package transport + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" +) + +type AssetHandler struct { + secret string + app *bootstrap.App +} + +func NewAssetHandler(app *bootstrap.App) *AssetHandler { + return &AssetHandler{ + secret: strings.TrimSpace(app.Config.JWT.Secret), + app: app, + } +} + +func (h *AssetHandler) BuildArticleImageURL(objectKey string) string { + token := h.signObjectKey(objectKey) + return "/api/public/assets/" + token +} + +func (h *AssetHandler) Serve(c *gin.Context) { + objectKey, ok := h.parseToken(c.Param("token")) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + + content, err := h.app.ObjectStorage.GetBytes(c.Request.Context(), objectKey) + if err != nil || len(content) == 0 { + c.AbortWithStatus(http.StatusNotFound) + return + } + + contentType := http.DetectContentType(content) + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Header("Content-Type", contentType) + c.Writer.WriteHeader(http.StatusOK) + _, _ = c.Writer.Write(content) +} + +func (h *AssetHandler) signObjectKey(objectKey string) string { + encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey)) + mac := hmac.New(sha256.New, []byte(h.secret)) + _, _ = mac.Write([]byte(objectKey)) + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return fmt.Sprintf("%s.%s", encodedKey, signature) +} + +func (h *AssetHandler) parseToken(token string) (string, bool) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return "", false + } + + objectKeyBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + objectKey := string(objectKeyBytes) + if strings.TrimSpace(objectKey) == "" { + return "", false + } + + expected := h.signObjectKey(objectKey) + if !hmac.Equal([]byte(expected), []byte(token)) { + return "", false + } + + return objectKey, true +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index a483030..1bd3314 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -12,6 +12,9 @@ func RegisterRoutes(app *bootstrap.App) { authAPI.POST("/login", authHandler.Login) authAPI.POST("/refresh", authHandler.Refresh) + publicAssets := NewAssetHandler(app) + app.Engine.GET("/api/public/assets/:token", publicAssets.Serve) + pluginHandler := NewMediaHandler(app) callbacks := app.Engine.Group("/api/callback/plugin") callbacks.POST("/bind", pluginHandler.PluginBindCallback) @@ -48,6 +51,7 @@ func RegisterRoutes(app *bootstrap.App) { artHandler := NewArticleHandler(app) articles.GET("", artHandler.List) articles.POST("/generate-from-rule", artHandler.GenerateFromRule) + articles.POST("/:id/images", artHandler.UploadImage) articles.GET("/:id", artHandler.Detail) articles.PUT("/:id", artHandler.Update) articles.GET("/:id/stream", artHandler.Stream)