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.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
UndoOutlined,
|
UndoOutlined,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
||||||
import {
|
import {
|
||||||
insertHrCommand,
|
insertHrCommand,
|
||||||
@@ -30,13 +31,16 @@ import { Crepe, CrepeFeature } from "@milkdown/crepe";
|
|||||||
import "@milkdown/crepe/theme/common/style.css";
|
import "@milkdown/crepe/theme/common/style.css";
|
||||||
import "@milkdown/crepe/theme/frame.css";
|
import "@milkdown/crepe/theme/frame.css";
|
||||||
import { Milkdown, useEditor, useInstance } from "@milkdown/vue";
|
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 { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import { formatError } from "@/lib/errors";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
uploadImage?: (file: File) => Promise<string>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -47,6 +51,17 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const crepeRef = ref<Crepe | null>(null);
|
const crepeRef = ref<Crepe | null>(null);
|
||||||
const syncingExternalMarkdown = ref(false);
|
const syncingExternalMarkdown = ref(false);
|
||||||
|
const tablePickerRef = ref<HTMLElement | null>(null);
|
||||||
|
const tableGridRef = ref<HTMLElement | null>(null);
|
||||||
|
const imageInputRef = ref<HTMLInputElement | null>(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 { loading } = useEditor((root) => {
|
||||||
const crepe = new Crepe({
|
const crepe = new Crepe({
|
||||||
@@ -78,6 +93,37 @@ const { loading } = useEditor((root) => {
|
|||||||
|
|
||||||
const [instanceLoading, getEditor] = useInstance();
|
const [instanceLoading, getEditor] = useInstance();
|
||||||
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
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 {
|
function syncMarkdownFromProps(nextValue: string): void {
|
||||||
const editor = getEditor();
|
const editor = getEditor();
|
||||||
@@ -102,6 +148,9 @@ watch(
|
|||||||
() => props.disabled,
|
() => props.disabled,
|
||||||
(value) => {
|
(value) => {
|
||||||
crepeRef.value?.setReadonly(!!value);
|
crepeRef.value?.setReadonly(!!value);
|
||||||
|
if (value) {
|
||||||
|
closeTablePicker();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -119,9 +168,18 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("pointerdown", handleWindowPointerDown);
|
||||||
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||||
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||||
crepeRef.value = null;
|
crepeRef.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("pointerdown", handleWindowPointerDown);
|
||||||
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
||||||
|
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||||
|
});
|
||||||
|
|
||||||
function runCommand(command: { key: unknown }, payload?: unknown): void {
|
function runCommand(command: { key: unknown }, payload?: unknown): void {
|
||||||
if (editorDisabled.value) {
|
if (editorDisabled.value) {
|
||||||
return;
|
return;
|
||||||
@@ -140,6 +198,11 @@ function insertImage(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.uploadImage) {
|
||||||
|
imageInputRef.value?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const src = window.prompt(t("article.editor.imagePrompt"));
|
const src = window.prompt(t("article.editor.imagePrompt"));
|
||||||
if (!src?.trim()) {
|
if (!src?.trim()) {
|
||||||
return;
|
return;
|
||||||
@@ -148,8 +211,167 @@ function insertImage(): void {
|
|||||||
runCommand(insertImageCommand, { src: src.trim() });
|
runCommand(insertImageCommand, { src: src.trim() });
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertTable(): void {
|
async function handleImageFileChange(event: Event): Promise<void> {
|
||||||
runCommand(insertTableCommand, { row: 3, col: 3 });
|
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);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -190,12 +412,58 @@ function insertTable(): void {
|
|||||||
<a-button size="small" type="text" @click="runCommand(wrapInOrderedListCommand)">
|
<a-button size="small" type="text" @click="runCommand(wrapInOrderedListCommand)">
|
||||||
<template #icon><OrderedListOutlined /></template>
|
<template #icon><OrderedListOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-button size="small" type="text" @click="insertTable">
|
<div ref="tablePickerRef" class="article-editor-canvas__table-trigger">
|
||||||
<template #icon><TableOutlined /></template>
|
<a-button
|
||||||
</a-button>
|
size="small"
|
||||||
|
type="text"
|
||||||
|
class="article-editor-canvas__toolbar-button"
|
||||||
|
:class="{ 'article-editor-canvas__toolbar-button--active': tablePickerOpen }"
|
||||||
|
@click="toggleTablePicker"
|
||||||
|
>
|
||||||
|
<template #icon><TableOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<div v-if="tablePickerOpen" class="article-editor-canvas__table-panel" @pointerdown.stop>
|
||||||
|
<div class="article-editor-canvas__table-panel-status">{{ tableSelectionLabel }}</div>
|
||||||
|
<div
|
||||||
|
ref="tableGridRef"
|
||||||
|
class="article-editor-canvas__table-grid"
|
||||||
|
:style="tablePickerGridStyle"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="cell in tablePickerCells"
|
||||||
|
:key="cell.key"
|
||||||
|
type="button"
|
||||||
|
class="article-editor-canvas__table-cell"
|
||||||
|
:class="{ 'article-editor-canvas__table-cell--active': cell.row <= tablePreviewRow && cell.col <= tablePreviewCol }"
|
||||||
|
@mouseenter="updateTablePreview(cell.row, cell.col)"
|
||||||
|
@focus="updateTablePreview(cell.row, cell.col)"
|
||||||
|
@pointerdown.prevent.stop="beginTableSelection(cell.row, cell.col)"
|
||||||
|
@keydown.enter.prevent="insertTableFromSelection(cell.row, cell.col)"
|
||||||
|
@keydown.space.prevent="insertTableFromSelection(cell.row, cell.col)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="article-editor-canvas__table-panel-hint">{{ t("article.editor.tablePicker.hint") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="article-editor-canvas__divider"></span>
|
<span class="article-editor-canvas__divider"></span>
|
||||||
<a-button size="small" type="text" @click="insertImage">
|
<input
|
||||||
|
ref="imageInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
hidden
|
||||||
|
@change="handleImageFileChange"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
:loading="imageUploading"
|
||||||
|
:disabled="imageUploading"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="insertImage"
|
||||||
|
>
|
||||||
<template #icon><PictureOutlined /></template>
|
<template #icon><PictureOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,6 +509,8 @@ function insertTable(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-canvas__toolbar {
|
.article-editor-canvas__toolbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -258,8 +528,86 @@ function insertTable(): void {
|
|||||||
background: #e5ebf5;
|
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 {
|
.article-editor-canvas__surface {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -267,7 +615,7 @@ function insertTable(): void {
|
|||||||
|
|
||||||
.article-editor-canvas__title-row {
|
.article-editor-canvas__title-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 0;
|
||||||
padding: 18px 28px 0;
|
padding: 18px 28px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ const enUS = {
|
|||||||
coverHint: "Upload a cover asset before publishing. This is local preview only for now.",
|
coverHint: "Upload a cover asset before publishing. This is local preview only for now.",
|
||||||
coverUpload: "Upload cover image",
|
coverUpload: "Upload cover image",
|
||||||
imagePrompt: "Enter image URL",
|
imagePrompt: "Enter image URL",
|
||||||
|
tablePicker: {
|
||||||
|
empty: "Drag to choose a table size",
|
||||||
|
hint: "Keep dragging near the edge to expand the grid",
|
||||||
|
},
|
||||||
messages: {
|
messages: {
|
||||||
saved: "Article saved.",
|
saved: "Article saved.",
|
||||||
generating: "This article is still generating. Try editing it later.",
|
generating: "This article is still generating. Try editing it later.",
|
||||||
|
|||||||
@@ -413,6 +413,10 @@ const zhCN = {
|
|||||||
coverHint: "发布前可先上传封面素材,当前只做本地预览。",
|
coverHint: "发布前可先上传封面素材,当前只做本地预览。",
|
||||||
coverUpload: "上传封面图",
|
coverUpload: "上传封面图",
|
||||||
imagePrompt: "请输入图片地址",
|
imagePrompt: "请输入图片地址",
|
||||||
|
tablePicker: {
|
||||||
|
empty: "拖拽选择表格尺寸",
|
||||||
|
hint: "按住拖动到边缘可继续扩展",
|
||||||
|
},
|
||||||
messages: {
|
messages: {
|
||||||
saved: "文章已保存",
|
saved: "文章已保存",
|
||||||
generating: "文章还在生成中,请稍后再编辑。",
|
generating: "文章还在生成中,请稍后再编辑。",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createApiClient } from "@geo/http-client";
|
import { createApiClient } from "@geo/http-client";
|
||||||
import type {
|
import type {
|
||||||
|
ApiEnvelope,
|
||||||
ArticleDetail,
|
ArticleDetail,
|
||||||
|
ArticleImageUploadResponse,
|
||||||
ArticleListParams,
|
ArticleListParams,
|
||||||
ArticleListResponse,
|
ArticleListResponse,
|
||||||
ArticleVersion,
|
ArticleVersion,
|
||||||
@@ -214,6 +216,17 @@ export const articlesApi = {
|
|||||||
update(id: number, payload: UpdateArticleRequest) {
|
update(id: number, payload: UpdateArticleRequest) {
|
||||||
return apiClient.put<ArticleDetail, UpdateArticleRequest>(`/api/tenant/articles/${id}`, payload);
|
return apiClient.put<ArticleDetail, UpdateArticleRequest>(`/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<ApiEnvelope<ArticleImageUploadResponse>>(
|
||||||
|
`/api/tenant/articles/${id}/images`,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
versions(id: number) {
|
versions(id: number) {
|
||||||
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,8 +24,15 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
outline_task_not_found: "文章大纲任务不存在或已失效",
|
outline_task_not_found: "文章大纲任务不存在或已失效",
|
||||||
article_generating: "文章仍在生成中",
|
article_generating: "文章仍在生成中",
|
||||||
article_not_editable: "只有已完成文章才可编辑",
|
article_not_editable: "只有已完成文章才可编辑",
|
||||||
|
article_lookup_failed: "文章状态读取失败",
|
||||||
draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑",
|
draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑",
|
||||||
article_title_required: "请输入文章标题",
|
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: "请求参数不合法",
|
invalid_payload: "请求参数不合法",
|
||||||
update_failed: "保存文章失败",
|
update_failed: "保存文章失败",
|
||||||
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ async function handlePublish(): Promise<void> {
|
|||||||
publishModalOpen.value = true;
|
publishModalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadEditorImage(file: File): Promise<string> {
|
||||||
|
const result = await articlesApi.uploadImage(articleId.value, file);
|
||||||
|
return result.url;
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePublished(): Promise<void> {
|
async function handlePublished(): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
detailQuery.refetch(),
|
detailQuery.refetch(),
|
||||||
@@ -273,6 +278,7 @@ function serializePlatformSelection(platformIds: string[]): string {
|
|||||||
v-model:title="title"
|
v-model:title="title"
|
||||||
v-model="markdown"
|
v-model="markdown"
|
||||||
:disabled="editorLocked"
|
:disabled="editorLocked"
|
||||||
|
:upload-image="uploadEditorImage"
|
||||||
/>
|
/>
|
||||||
</MilkdownProvider>
|
</MilkdownProvider>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -273,6 +273,13 @@ export interface UpdateArticleRequest {
|
|||||||
platforms?: string[];
|
platforms?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArticleImageUploadResponse {
|
||||||
|
url: string;
|
||||||
|
object_key: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ArticleVersion {
|
export interface ArticleVersion {
|
||||||
id: number;
|
id: number;
|
||||||
version_no: number;
|
version_no: number;
|
||||||
|
|||||||
@@ -25,9 +25,26 @@ object_storage:
|
|||||||
endpoint: localhost:9000
|
endpoint: localhost:9000
|
||||||
bucket: geo-private
|
bucket: geo-private
|
||||||
use_ssl: false
|
use_ssl: false
|
||||||
|
public_base_url: ""
|
||||||
|
region: ""
|
||||||
# Prefer environment override for secrets:
|
# Prefer environment override for secrets:
|
||||||
# export OBJECT_STORAGE_ACCESS_KEY=minioadmin
|
# export OBJECT_STORAGE_ACCESS_KEY=minioadmin
|
||||||
# export OBJECT_STORAGE_SECRET_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:
|
jwt:
|
||||||
secret: "your-local-secret"
|
secret: "your-local-secret"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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/sync v0.10.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/text v0.21.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/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
|
||||||
google.golang.org/grpc v1.66.0 // indirect
|
google.golang.org/grpc v1.66.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
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/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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
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.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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
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-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-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ func applyEnvOverrides(cfg *Config) {
|
|||||||
if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok {
|
if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok {
|
||||||
cfg.ObjectStorage.PublicBaseURL = publicBaseURL
|
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 {
|
if apiKey, ok := lookupFirstNonEmptyEnv("SILICONFLOW_API_KEY", "RETRIEVAL_API_KEY", "EMBEDDING_API_KEY", "RERANKER_API_KEY"); ok {
|
||||||
cfg.Retrieval.APIKey = apiKey
|
cfg.Retrieval.APIKey = apiKey
|
||||||
}
|
}
|
||||||
@@ -217,3 +223,19 @@ func lookupNonEmptyEnv(key string) (string, bool) {
|
|||||||
|
|
||||||
return value, true
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type Client interface {
|
|||||||
PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error
|
PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error
|
||||||
GetBytes(ctx context.Context, objectKey string) ([]byte, error)
|
GetBytes(ctx context.Context, objectKey string) ([]byte, error)
|
||||||
Delete(ctx context.Context, objectKey string) error
|
Delete(ctx context.Context, objectKey string) error
|
||||||
|
PublicURL(objectKey string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
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"}
|
return disabledClient{reason: "object storage is disabled"}
|
||||||
case "minio":
|
case "minio":
|
||||||
return NewMinIOClient(cfg, logger)
|
return NewMinIOClient(cfg, logger)
|
||||||
|
case "aliyun", "aliyun_oss", "aliyun-oss", "oss":
|
||||||
|
return NewAliyunClient(cfg, logger)
|
||||||
default:
|
default:
|
||||||
return disabledClient{reason: fmt.Sprintf("unsupported object storage provider %q", cfg.Provider)}
|
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 {
|
func (c disabledClient) Delete(context.Context, string) error {
|
||||||
return c.Validate()
|
return c.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c disabledClient) PublicURL(string) (string, error) {
|
||||||
|
return "", c.Validate()
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type minioClient struct {
|
|||||||
bucket string
|
bucket string
|
||||||
region string
|
region string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
cfg config.ObjectStorageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||||
@@ -45,6 +46,7 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
|||||||
bucket: bucket,
|
bucket: bucket,
|
||||||
region: strings.TrimSpace(cfg.Region),
|
region: strings.TrimSpace(cfg.Region),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,13 @@ func (c *minioClient) Delete(ctx context.Context, objectKey string) error {
|
|||||||
return nil
|
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) {
|
func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
|
||||||
if c == nil || c.logger == nil {
|
if c == nil || c.logger == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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, "/")
|
||||||
|
}
|
||||||
@@ -3,26 +3,43 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
"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/auth"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
"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/middleware"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ArticleService struct {
|
type ArticleService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
auditLogs *auditlog.AsyncWriter
|
auditLogs *auditlog.AsyncWriter
|
||||||
|
objectStorage objectstorage.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ArticleService {
|
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
||||||
return &ArticleService{pool: pool, auditLogs: auditLogs}
|
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 {
|
type ArticleListParams struct {
|
||||||
@@ -303,6 +320,13 @@ type UpdateArticleRequest struct {
|
|||||||
Platforms []string `json:"platforms"`
|
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) {
|
func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
|
|
||||||
@@ -383,6 +407,54 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
return s.Detail(ctx, id)
|
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 {
|
type VersionItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
VersionNo int `json:"version_no"`
|
VersionNo int `json:"version_no"`
|
||||||
@@ -466,6 +538,49 @@ func countArticleWords(input string) int {
|
|||||||
return contentstats.CountWords(input)
|
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 {
|
func resolveWordCount(markdown *string, stored int) int {
|
||||||
if markdown != nil {
|
if markdown != nil {
|
||||||
if counted := countArticleWords(*markdown); counted > 0 {
|
if counted := countArticleWords(*markdown); counted > 0 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package transport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +20,7 @@ type ArticleHandler struct {
|
|||||||
promptGenerateSvc *app.PromptRuleGenerationService
|
promptGenerateSvc *app.PromptRuleGenerationService
|
||||||
streams *stream.GenerationHub
|
streams *stream.GenerationHub
|
||||||
streamEnabled bool
|
streamEnabled bool
|
||||||
|
assets *AssetHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
||||||
@@ -36,7 +38,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return &ArticleHandler{
|
return &ArticleHandler{
|
||||||
svc: app.NewArticleService(a.DB, a.AuditLogs),
|
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage),
|
||||||
promptGenerateSvc: app.NewPromptRuleGenerationService(
|
promptGenerateSvc: app.NewPromptRuleGenerationService(
|
||||||
a.DB,
|
a.DB,
|
||||||
a.LLM,
|
a.LLM,
|
||||||
@@ -47,6 +49,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
|||||||
),
|
),
|
||||||
streams: a.GenerationStreams,
|
streams: a.GenerationStreams,
|
||||||
streamEnabled: a.Config.Generation.StreamEnabled,
|
streamEnabled: a.Config.Generation.StreamEnabled,
|
||||||
|
assets: NewAssetHandler(a),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +174,42 @@ func (h *ArticleHandler) Update(c *gin.Context) {
|
|||||||
response.Success(c, data)
|
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) {
|
func (h *ArticleHandler) Stream(c *gin.Context) {
|
||||||
if !h.streamEnabled {
|
if !h.streamEnabled {
|
||||||
response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled"))
|
response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
authAPI.POST("/login", authHandler.Login)
|
authAPI.POST("/login", authHandler.Login)
|
||||||
authAPI.POST("/refresh", authHandler.Refresh)
|
authAPI.POST("/refresh", authHandler.Refresh)
|
||||||
|
|
||||||
|
publicAssets := NewAssetHandler(app)
|
||||||
|
app.Engine.GET("/api/public/assets/:token", publicAssets.Serve)
|
||||||
|
|
||||||
pluginHandler := NewMediaHandler(app)
|
pluginHandler := NewMediaHandler(app)
|
||||||
callbacks := app.Engine.Group("/api/callback/plugin")
|
callbacks := app.Engine.Group("/api/callback/plugin")
|
||||||
callbacks.POST("/bind", pluginHandler.PluginBindCallback)
|
callbacks.POST("/bind", pluginHandler.PluginBindCallback)
|
||||||
@@ -48,6 +51,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
artHandler := NewArticleHandler(app)
|
artHandler := NewArticleHandler(app)
|
||||||
articles.GET("", artHandler.List)
|
articles.GET("", artHandler.List)
|
||||||
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
||||||
|
articles.POST("/:id/images", artHandler.UploadImage)
|
||||||
articles.GET("/:id", artHandler.Detail)
|
articles.GET("/:id", artHandler.Detail)
|
||||||
articles.PUT("/:id", artHandler.Update)
|
articles.PUT("/:id", artHandler.Update)
|
||||||
articles.GET("/:id/stream", artHandler.Stream)
|
articles.GET("/:id/stream", artHandler.Stream)
|
||||||
|
|||||||
Reference in New Issue
Block a user