fix:图片上传成功
This commit is contained in:
@@ -168,9 +168,9 @@ const eventBody = (payload: EventPayload) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageBody = (payload: ImagePayload) => ({
|
const imageBody = (payload: ImagePayload) => ({
|
||||||
|
imageURL: requiredText(payload.image_url, "请先上传图片,上传成功后系统会自动填充图片字段"),
|
||||||
imageName: requiredText(payload.image_name, "Image name is required"),
|
imageName: requiredText(payload.image_name, "Image name is required"),
|
||||||
imageFileName: optionalText(payload.image_file_name),
|
imageFileName: optionalText(payload.image_file_name),
|
||||||
imageURL: optionalText(payload.image_url),
|
|
||||||
mimeType: optionalText(payload.mime_type),
|
mimeType: optionalText(payload.mime_type),
|
||||||
description: optionalText(payload.description),
|
description: optionalText(payload.description),
|
||||||
studyDbId: optionalText(payload.study_db_id),
|
studyDbId: optionalText(payload.study_db_id),
|
||||||
|
|||||||
@@ -29,26 +29,17 @@ function formatFileDate(timestamp: number): string {
|
|||||||
function buildAutoFillPatch(
|
function buildAutoFillPatch(
|
||||||
image: UploadedImage,
|
image: UploadedImage,
|
||||||
file: File,
|
file: File,
|
||||||
current: Record<string, unknown>,
|
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const baseName = stripExtension(image.filename);
|
const baseName = stripExtension(image.filename).trim();
|
||||||
const patch: Record<string, string> = {
|
const imageId = toImageId(baseName);
|
||||||
|
return {
|
||||||
|
id: imageId,
|
||||||
|
image_name: baseName || imageId,
|
||||||
image_file_name: image.filename,
|
image_file_name: image.filename,
|
||||||
image_url: image.url,
|
image_url: image.url,
|
||||||
mime_type: image.contentType,
|
mime_type: image.contentType,
|
||||||
|
image_time_stamp: formatFileDate(file.lastModified),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!String(current.id ?? "").trim()) {
|
|
||||||
patch.id = toImageId(baseName);
|
|
||||||
}
|
|
||||||
if (!String(current.image_name ?? "").trim()) {
|
|
||||||
patch.image_name = baseName;
|
|
||||||
}
|
|
||||||
if (!String(current.image_time_stamp ?? "").trim()) {
|
|
||||||
patch.image_time_stamp = formatFileDate(file.lastModified);
|
|
||||||
}
|
|
||||||
|
|
||||||
return patch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageFormUploadProps = {
|
type ImageFormUploadProps = {
|
||||||
@@ -57,15 +48,22 @@ type ImageFormUploadProps = {
|
|||||||
updateFormBatch: (patch: Record<string, unknown>) => void;
|
updateFormBatch: (patch: Record<string, unknown>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImageFormUpload({ formData, updateForm, updateFormBatch }: ImageFormUploadProps) {
|
export function ImageFormUpload({ formData, updateFormBatch }: ImageFormUploadProps) {
|
||||||
const imageUrl = String(formData.image_url ?? "").trim();
|
const imageUrl = String(formData.image_url ?? "").trim();
|
||||||
|
|
||||||
const handleUploaded = (image: UploadedImage, file: File) => {
|
const handleUploaded = (image: UploadedImage, file: File) => {
|
||||||
updateFormBatch(buildAutoFillPatch(image, file, formData));
|
updateFormBatch(buildAutoFillPatch(image, file));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoved = () => {
|
const handleRemoved = () => {
|
||||||
updateForm("image_url", "");
|
updateFormBatch({
|
||||||
|
id: "",
|
||||||
|
image_name: "",
|
||||||
|
image_file_name: "",
|
||||||
|
image_url: "",
|
||||||
|
mime_type: "",
|
||||||
|
image_time_stamp: "",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const eventTypeOptions: SelectOption[] = [
|
|||||||
|
|
||||||
const mimeTypeOptions: SelectOption[] = [
|
const mimeTypeOptions: SelectOption[] = [
|
||||||
{ value: "image/jpeg", label: "image/jpeg" },
|
{ value: "image/jpeg", label: "image/jpeg" },
|
||||||
|
{ value: "image/jpg", label: "image/jpg" },
|
||||||
{ value: "image/png", label: "image/png" },
|
{ value: "image/png", label: "image/png" },
|
||||||
{ value: "image/gif", label: "image/gif" },
|
{ value: "image/gif", label: "image/gif" },
|
||||||
{ value: "image/webp", label: "image/webp" },
|
{ value: "image/webp", label: "image/webp" },
|
||||||
@@ -97,12 +98,12 @@ export default function EventImagePage() {
|
|||||||
], [studyOptions]);
|
], [studyOptions]);
|
||||||
|
|
||||||
const imageFields = useMemo<BrapiFormField[]>(() => [
|
const imageFields = useMemo<BrapiFormField[]>(() => [
|
||||||
{ key: "id", label: "Image ID", type: "text", required: true, placeholder: "image-001" },
|
{ key: "id", label: "Image ID", type: "text", required: true, readOnly: true, placeholder: "上传图片后自动生成" },
|
||||||
{ key: "image_name", label: "Image Name", type: "text", required: true, placeholder: "plot-001-canopy" },
|
{ key: "image_name", label: "Image Name", type: "text", required: true, readOnly: true, placeholder: "上传图片后自动填写" },
|
||||||
{ key: "image_file_name", label: "File Name", type: "text", placeholder: "image-001.jpg" },
|
{ key: "image_file_name", label: "File Name", type: "text", readOnly: true, placeholder: "上传图片后自动填写" },
|
||||||
{ key: "image_url", label: "Image URL", type: "text", placeholder: "https://example.org/image.jpg", colSpan: 2 },
|
{ key: "image_url", label: "Image URL", type: "text", required: true, readOnly: true, placeholder: "上传图片后自动填写", colSpan: 2 },
|
||||||
{ key: "mime_type", label: "MIME Type", type: "select", options: mimeTypeOptions },
|
{ key: "mime_type", label: "MIME Type", type: "select", readOnly: true, options: mimeTypeOptions },
|
||||||
{ key: "image_time_stamp", label: "Image Date", type: "date" },
|
{ key: "image_time_stamp", label: "Image Date", type: "date", readOnly: true },
|
||||||
{
|
{
|
||||||
key: "study_db_id",
|
key: "study_db_id",
|
||||||
label: "Study",
|
label: "Study",
|
||||||
@@ -125,6 +126,14 @@ export default function EventImagePage() {
|
|||||||
{ key: "description", label: "Description", type: "textarea", colSpan: 2 },
|
{ key: "description", label: "Description", type: "textarea", colSpan: 2 },
|
||||||
], [observationOptions, observationUnitOptions, studyOptions]);
|
], [observationOptions, observationUnitOptions, studyOptions]);
|
||||||
|
|
||||||
|
const validateImageForm = useCallback((payload: Record<string, unknown>) => {
|
||||||
|
const imageUrl = String(payload.image_url ?? "").trim();
|
||||||
|
if (!imageUrl) {
|
||||||
|
return "请先上传图片,上传成功后系统会自动填充带 * 的图片字段。";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="events" className="flex min-h-full flex-col gap-4">
|
<Tabs defaultValue="events" className="flex min-h-full flex-col gap-4">
|
||||||
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
|
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
|
||||||
@@ -182,6 +191,7 @@ export default function EventImagePage() {
|
|||||||
stats={[{ label: "/brapi/v2/images", value: "BrAPI", className: "bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-200" }]}
|
stats={[{ label: "/brapi/v2/images", value: "BrAPI", className: "bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-200" }]}
|
||||||
loadData={loadImages}
|
loadData={loadImages}
|
||||||
renderFormExtra={(props) => <ImageFormUpload {...props} />}
|
renderFormExtra={(props) => <ImageFormUpload {...props} />}
|
||||||
|
validateForm={validateImageForm}
|
||||||
createRecord={(payload) => createImageRow(payload) as unknown as Promise<Record<string, unknown>>}
|
createRecord={(payload) => createImageRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||||
updateRecord={(id, payload) => updateImageRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
updateRecord={(id, payload) => updateImageRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||||
deleteRecord={deleteImageRow}
|
deleteRecord={deleteImageRow}
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ function YearFormField({
|
|||||||
onFieldChange,
|
onFieldChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
required,
|
required,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onFieldChange: (key: string, value: string) => void;
|
onFieldChange: (key: string, value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const yearValue = useMemo(() => parseYearValue(value), [value]);
|
const yearValue = useMemo(() => parseYearValue(value), [value]);
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@@ -108,6 +110,7 @@ function YearFormField({
|
|||||||
placeholder={placeholder ?? "请选择年份"}
|
placeholder={placeholder ?? "请选择年份"}
|
||||||
clearable={!required}
|
clearable={!required}
|
||||||
required={required}
|
required={required}
|
||||||
|
readOnly={readOnly}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -119,12 +122,14 @@ function DateFormField({
|
|||||||
onFieldChange,
|
onFieldChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
required,
|
required,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onFieldChange: (key: string, value: string) => void;
|
onFieldChange: (key: string, value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const dateValue = useMemo(() => parseDateValue(value), [value]);
|
const dateValue = useMemo(() => parseDateValue(value), [value]);
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@@ -141,6 +146,7 @@ function DateFormField({
|
|||||||
placeholder={placeholder ?? "请选择日期"}
|
placeholder={placeholder ?? "请选择日期"}
|
||||||
clearable={!required}
|
clearable={!required}
|
||||||
required={required}
|
required={required}
|
||||||
|
readOnly={readOnly}
|
||||||
showToday
|
showToday
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -186,6 +192,7 @@ interface BrapiEntityPageProps {
|
|||||||
createRecord?: (payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
createRecord?: (payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
updateRecord?: (id: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
updateRecord?: (id: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
deleteRecord?: (id: string) => Promise<void>;
|
deleteRecord?: (id: string) => Promise<void>;
|
||||||
|
validateForm?: (payload: Record<string, unknown>, editingRow: Record<string, unknown> | null) => string | null | undefined;
|
||||||
renderQueryForm?: (props: { keyword: string; onKeywordChange: (value: string) => void }) => ReactNode;
|
renderQueryForm?: (props: { keyword: string; onKeywordChange: (value: string) => void }) => ReactNode;
|
||||||
renderFormExtra?: (props: {
|
renderFormExtra?: (props: {
|
||||||
formData: Record<string, unknown>;
|
formData: Record<string, unknown>;
|
||||||
@@ -214,6 +221,7 @@ export function BrapiEntityPage({
|
|||||||
createRecord,
|
createRecord,
|
||||||
updateRecord,
|
updateRecord,
|
||||||
deleteRecord,
|
deleteRecord,
|
||||||
|
validateForm,
|
||||||
renderQueryForm,
|
renderQueryForm,
|
||||||
renderFormExtra,
|
renderFormExtra,
|
||||||
useEnhancedDialog = false,
|
useEnhancedDialog = false,
|
||||||
@@ -225,6 +233,7 @@ export function BrapiEntityPage({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editingRow, setEditingRow] = useState<Record<string, unknown> | null>(null);
|
const [editingRow, setEditingRow] = useState<Record<string, unknown> | null>(null);
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [deletingRow, setDeletingRow] = useState<Record<string, unknown> | null>(null);
|
const [deletingRow, setDeletingRow] = useState<Record<string, unknown> | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -313,6 +322,7 @@ export function BrapiEntityPage({
|
|||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
setEditingRow(null);
|
setEditingRow(null);
|
||||||
|
setFormError(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
...fields.reduce<Record<string, unknown>>((defaults, field) => {
|
...fields.reduce<Record<string, unknown>>((defaults, field) => {
|
||||||
if (field.type === "year") {
|
if (field.type === "year") {
|
||||||
@@ -334,6 +344,7 @@ export function BrapiEntityPage({
|
|||||||
const detail = await resolveRecord(row);
|
const detail = await resolveRecord(row);
|
||||||
setEditingRow(detail);
|
setEditingRow(detail);
|
||||||
setFormData({ ...detail });
|
setFormData({ ...detail });
|
||||||
|
setFormError(null);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (event) {
|
} catch (event) {
|
||||||
@@ -361,7 +372,15 @@ export function BrapiEntityPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
const validationError = validateForm?.(formData, editingRow);
|
||||||
|
if (validationError) {
|
||||||
|
setFormError(validationError);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setFormError(null);
|
||||||
try {
|
try {
|
||||||
if (editingRow) {
|
if (editingRow) {
|
||||||
const rowId = resolveRowId(editingRow);
|
const rowId = resolveRowId(editingRow);
|
||||||
@@ -396,7 +415,9 @@ export function BrapiEntityPage({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (event) {
|
} catch (event) {
|
||||||
setError(event instanceof Error ? event.message : "保存失败");
|
const message = event instanceof Error ? event.message : "保存失败";
|
||||||
|
setFormError(message);
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -428,10 +449,12 @@ export function BrapiEntityPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateForm = useCallback((key: string, value: string) => {
|
const updateForm = useCallback((key: string, value: string) => {
|
||||||
|
setFormError(null);
|
||||||
setFormData((current) => ({ ...current, [key]: value }));
|
setFormData((current) => ({ ...current, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateFormBatch = useCallback((patch: Record<string, unknown>) => {
|
const updateFormBatch = useCallback((patch: Record<string, unknown>) => {
|
||||||
|
setFormError(null);
|
||||||
setFormData((current) => ({ ...current, ...patch }));
|
setFormData((current) => ({ ...current, ...patch }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -462,6 +485,7 @@ export function BrapiEntityPage({
|
|||||||
onFieldChange={updateForm}
|
onFieldChange={updateForm}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
|
readOnly={field.readOnly}
|
||||||
/>
|
/>
|
||||||
) : field.type === "date" ? (
|
) : field.type === "date" ? (
|
||||||
<DateFormField
|
<DateFormField
|
||||||
@@ -470,6 +494,7 @@ export function BrapiEntityPage({
|
|||||||
onFieldChange={updateForm}
|
onFieldChange={updateForm}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
|
readOnly={field.readOnly}
|
||||||
/>
|
/>
|
||||||
) : field.type === "select" ? (
|
) : field.type === "select" ? (
|
||||||
<Select
|
<Select
|
||||||
@@ -678,7 +703,10 @@ export function BrapiEntityPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{useEnhancedDialog ? (
|
{useEnhancedDialog ? (
|
||||||
<EnhancedDialog open={open} onOpenChange={setOpen}>
|
<EnhancedDialog open={open} onOpenChange={(nextOpen) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) setFormError(null);
|
||||||
|
}}>
|
||||||
<EnhancedDialogContent
|
<EnhancedDialogContent
|
||||||
title={formDialogTitle}
|
title={formDialogTitle}
|
||||||
defaultWidth={960}
|
defaultWidth={960}
|
||||||
@@ -687,6 +715,11 @@ export function BrapiEntityPage({
|
|||||||
>
|
>
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
<p className="mb-4 text-sm text-muted-foreground">请填写以下字段,带 * 号为必填项。</p>
|
<p className="mb-4 text-sm text-muted-foreground">请填写以下字段,带 * 号为必填项。</p>
|
||||||
|
{formError ? (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{formFieldsGrid}
|
{formFieldsGrid}
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<EnhancedDialogFooter className="flex justify-end gap-2">
|
<EnhancedDialogFooter className="flex justify-end gap-2">
|
||||||
@@ -695,12 +728,20 @@ export function BrapiEntityPage({
|
|||||||
</EnhancedDialogContent>
|
</EnhancedDialogContent>
|
||||||
</EnhancedDialog>
|
</EnhancedDialog>
|
||||||
) : (
|
) : (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) setFormError(null);
|
||||||
|
}}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{formDialogTitle}</DialogTitle>
|
<DialogTitle>{formDialogTitle}</DialogTitle>
|
||||||
<DialogDescription>请填写以下字段,带 * 号为必填项。</DialogDescription>
|
<DialogDescription>请填写以下字段,带 * 号为必填项。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{formError ? (
|
||||||
|
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="py-2">{formFieldsGrid}</div>
|
<div className="py-2">{formFieldsGrid}</div>
|
||||||
<DialogFooter className="mt-2">{formDialogFooter}</DialogFooter>
|
<DialogFooter className="mt-2">{formDialogFooter}</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
6
pom.xml
6
pom.xml
@@ -35,6 +35,7 @@
|
|||||||
<jackson-datatype-threetenbp-version>2.8.4</jackson-datatype-threetenbp-version>
|
<jackson-datatype-threetenbp-version>2.8.4</jackson-datatype-threetenbp-version>
|
||||||
<jakarta.annotation-version>3.0.0</jakarta.annotation-version>
|
<jakarta.annotation-version>3.0.0</jakarta.annotation-version>
|
||||||
<commons-lang3-version>3.18.0</commons-lang3-version>
|
<commons-lang3-version>3.18.0</commons-lang3-version>
|
||||||
|
<minio-version>8.5.17</minio-version>
|
||||||
<jaxb-api-version>2.3.1</jaxb-api-version>
|
<jaxb-api-version>2.3.1</jaxb-api-version>
|
||||||
<java-jwt-version>3.14.0</java-jwt-version>
|
<java-jwt-version>3.14.0</java-jwt-version>
|
||||||
<spring-boot-maven-plugin-version>3.4.0</spring-boot-maven-plugin-version>
|
<spring-boot-maven-plugin-version>3.4.0</spring-boot-maven-plugin-version>
|
||||||
@@ -165,6 +166,11 @@
|
|||||||
<artifactId>commons-lang3</artifactId>
|
<artifactId>commons-lang3</artifactId>
|
||||||
<version>${commons-lang3-version}</version>
|
<version>${commons-lang3-version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>${minio-version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>javax.xml.bind</groupId>
|
<groupId>javax.xml.bind</groupId>
|
||||||
<artifactId>jaxb-api</artifactId>
|
<artifactId>jaxb-api</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.brapi.test.BrAPITestServer.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class MinioConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MinioClient minioClient(MinioProperties properties) {
|
||||||
|
return MinioClient.builder()
|
||||||
|
.endpoint(properties.getEndpointUrl())
|
||||||
|
.credentials(properties.getAccessKey(), properties.getSecretKey())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package org.brapi.test.BrAPITestServer.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "minio")
|
||||||
|
public class MinioProperties {
|
||||||
|
private String endpoint = "minio:9000";
|
||||||
|
private String accessKey = "minioadmin";
|
||||||
|
private String secretKey = "ChangeMe_Minio_Strong_123456";
|
||||||
|
private String bucket = "images";
|
||||||
|
private boolean secure = false;
|
||||||
|
private boolean localFallbackEnabled = true;
|
||||||
|
private String localDirectory = "uploads/images";
|
||||||
|
|
||||||
|
public String getEndpoint() {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndpoint(String endpoint) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessKey() {
|
||||||
|
return accessKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessKey(String accessKey) {
|
||||||
|
this.accessKey = accessKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecretKey() {
|
||||||
|
return secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecretKey(String secretKey) {
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBucket() {
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBucket(String bucket) {
|
||||||
|
this.bucket = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSecure() {
|
||||||
|
return secure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecure(boolean secure) {
|
||||||
|
this.secure = secure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLocalFallbackEnabled() {
|
||||||
|
return localFallbackEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocalFallbackEnabled(boolean localFallbackEnabled) {
|
||||||
|
this.localFallbackEnabled = localFallbackEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocalDirectory() {
|
||||||
|
return localDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocalDirectory(String localDirectory) {
|
||||||
|
this.localDirectory = localDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEndpointUrl() {
|
||||||
|
if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
return (secure ? "https://" : "http://") + endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.brapi.test.BrAPITestServer.controller.upload;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.brapi.test.BrAPITestServer.model.dto.upload.ImageUploadResponse;
|
||||||
|
import org.brapi.test.BrAPITestServer.service.upload.MinioImageStorageService;
|
||||||
|
import org.brapi.test.BrAPITestServer.service.upload.MinioImageStorageService.StoredImage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import io.minio.errors.ErrorResponseException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/upload")
|
||||||
|
public class ImageUploadController {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(ImageUploadController.class);
|
||||||
|
|
||||||
|
private final MinioImageStorageService imageStorageService;
|
||||||
|
|
||||||
|
public ImageUploadController(MinioImageStorageService imageStorageService) {
|
||||||
|
this.imageStorageService = imageStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin
|
||||||
|
@PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||||
|
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<?> uploadImage(@RequestParam("file") MultipartFile file) {
|
||||||
|
try {
|
||||||
|
ImageUploadResponse response = imageStorageService.uploadImage(file);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("detail", e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("Image upload failed", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("detail", "image upload failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin
|
||||||
|
@GetMapping(value = "/image/{objectName:.+}")
|
||||||
|
public ResponseEntity<?> getImage(@PathVariable("objectName") String objectName) {
|
||||||
|
try {
|
||||||
|
StoredImage image = imageStorageService.loadImage(objectName);
|
||||||
|
InputStreamResource resource = new InputStreamResource(image.getInputStream());
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + objectName + "\"")
|
||||||
|
.contentLength(image.getSize())
|
||||||
|
.contentType(resolveContentType(image.getContentType()))
|
||||||
|
.body(resource);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("detail", e.getMessage()));
|
||||||
|
} catch (ErrorResponseException e) {
|
||||||
|
if ("NoSuchKey".equals(e.errorResponse().code()) || "NoSuchBucket".equals(e.errorResponse().code())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("detail", "image not found"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("detail", "image download failed"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("Image download failed for {}", objectName, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("detail", "image download failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaType resolveContentType(String contentType) {
|
||||||
|
if (contentType == null || contentType.isBlank()) {
|
||||||
|
return MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
|
return MediaType.parseMediaType(contentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.brapi.test.BrAPITestServer.model.dto.upload;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public class ImageUploadResponse {
|
||||||
|
@JsonProperty("url")
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@JsonProperty("filename")
|
||||||
|
private String filename;
|
||||||
|
|
||||||
|
@JsonProperty("content_type")
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
@JsonProperty("size")
|
||||||
|
private long size;
|
||||||
|
|
||||||
|
public ImageUploadResponse(String url, String filename, String contentType, long size) {
|
||||||
|
this.url = url;
|
||||||
|
this.filename = filename;
|
||||||
|
this.contentType = contentType;
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package org.brapi.test.BrAPITestServer.service.upload;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.brapi.test.BrAPITestServer.config.MinioProperties;
|
||||||
|
import org.brapi.test.BrAPITestServer.model.dto.upload.ImageUploadResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import io.minio.BucketExistsArgs;
|
||||||
|
import io.minio.GetObjectArgs;
|
||||||
|
import io.minio.MakeBucketArgs;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import io.minio.PutObjectArgs;
|
||||||
|
import io.minio.StatObjectArgs;
|
||||||
|
import io.minio.StatObjectResponse;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MinioImageStorageService {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(MinioImageStorageService.class);
|
||||||
|
private static final Set<String> ALLOWED_IMAGE_TYPES = Set.of(
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/bmp");
|
||||||
|
private static final long MAX_IMAGE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
private final MinioClient minioClient;
|
||||||
|
private final MinioProperties properties;
|
||||||
|
|
||||||
|
public MinioImageStorageService(MinioClient minioClient, MinioProperties properties) {
|
||||||
|
this.minioClient = minioClient;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImageUploadResponse uploadImage(MultipartFile file) throws Exception {
|
||||||
|
validateImage(file);
|
||||||
|
|
||||||
|
String filename = cleanFilename(file.getOriginalFilename());
|
||||||
|
String objectName = UUID.randomUUID().toString() + extensionOf(filename);
|
||||||
|
String contentType = file.getContentType().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureBucket();
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
minioClient.putObject(PutObjectArgs.builder()
|
||||||
|
.bucket(properties.getBucket())
|
||||||
|
.object(objectName)
|
||||||
|
.stream(inputStream, file.getSize(), -1)
|
||||||
|
.contentType(contentType)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (!properties.isLocalFallbackEnabled()) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
LOGGER.warn("MinIO image upload failed, storing image locally instead: {}", e.getMessage());
|
||||||
|
storeLocal(file, objectName, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodedObjectName = URLEncoder.encode(objectName, StandardCharsets.UTF_8);
|
||||||
|
return new ImageUploadResponse("/brapi/v2/upload/image/" + encodedObjectName,
|
||||||
|
filename, contentType, file.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredImage loadImage(String objectName) throws Exception {
|
||||||
|
validateObjectName(objectName);
|
||||||
|
Path localImagePath = localImagePath(objectName);
|
||||||
|
if (Files.isRegularFile(localImagePath)) {
|
||||||
|
return new StoredImage(Files.newInputStream(localImagePath),
|
||||||
|
Files.size(localImagePath),
|
||||||
|
localContentType(objectName));
|
||||||
|
}
|
||||||
|
|
||||||
|
StatObjectResponse stat = statImage(objectName);
|
||||||
|
InputStream inputStream = minioClient.getObject(GetObjectArgs.builder()
|
||||||
|
.bucket(properties.getBucket())
|
||||||
|
.object(objectName)
|
||||||
|
.build());
|
||||||
|
return new StoredImage(inputStream, stat.size(), stat.contentType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public StatObjectResponse statImage(String objectName) throws Exception {
|
||||||
|
validateObjectName(objectName);
|
||||||
|
return minioClient.statObject(StatObjectArgs.builder()
|
||||||
|
.bucket(properties.getBucket())
|
||||||
|
.object(objectName)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureBucket() throws Exception {
|
||||||
|
boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder()
|
||||||
|
.bucket(properties.getBucket())
|
||||||
|
.build());
|
||||||
|
if (!bucketExists) {
|
||||||
|
minioClient.makeBucket(MakeBucketArgs.builder()
|
||||||
|
.bucket(properties.getBucket())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateImage(MultipartFile file) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("file is required");
|
||||||
|
}
|
||||||
|
if (file.getSize() > MAX_IMAGE_SIZE) {
|
||||||
|
throw new IllegalArgumentException("image file must be 10 MB or smaller");
|
||||||
|
}
|
||||||
|
String contentType = file.getContentType();
|
||||||
|
if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) {
|
||||||
|
throw new IllegalArgumentException("only JPG, PNG, GIF, WebP, and BMP images are supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cleanFilename(String filename) {
|
||||||
|
String clean = StringUtils.cleanPath(filename == null ? "image" : filename);
|
||||||
|
if (clean.contains("..")) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
return clean.isBlank() ? "image" : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extensionOf(String filename) {
|
||||||
|
int dotIndex = filename.lastIndexOf('.');
|
||||||
|
if (dotIndex < 0 || dotIndex == filename.length() - 1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return filename.substring(dotIndex).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateObjectName(String objectName) {
|
||||||
|
if (objectName == null || objectName.isBlank()
|
||||||
|
|| objectName.contains("/") || objectName.contains("\\") || objectName.contains("..")) {
|
||||||
|
throw new IllegalArgumentException("invalid object name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void storeLocal(MultipartFile file, String objectName, String contentType) throws Exception {
|
||||||
|
Path imagePath = localImagePath(objectName);
|
||||||
|
Files.createDirectories(imagePath.getParent());
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
Files.copy(inputStream, imagePath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
Properties metadata = new Properties();
|
||||||
|
metadata.setProperty("contentType", contentType);
|
||||||
|
try (OutputStream outputStream = Files.newOutputStream(localMetadataPath(objectName))) {
|
||||||
|
metadata.store(outputStream, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path localImagePath(String objectName) {
|
||||||
|
return localStorageDirectory().resolve(objectName).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path localMetadataPath(String objectName) {
|
||||||
|
return localStorageDirectory().resolve(objectName + ".properties").normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path localStorageDirectory() {
|
||||||
|
return Paths.get(properties.getLocalDirectory()).toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String localContentType(String objectName) {
|
||||||
|
Path metadataPath = localMetadataPath(objectName);
|
||||||
|
if (Files.isRegularFile(metadataPath)) {
|
||||||
|
Properties metadata = new Properties();
|
||||||
|
try (InputStream inputStream = Files.newInputStream(metadataPath)) {
|
||||||
|
metadata.load(inputStream);
|
||||||
|
String contentType = metadata.getProperty("contentType");
|
||||||
|
if (contentType != null && !contentType.isBlank()) {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("Failed to read local image metadata for {}: {}", objectName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contentTypeFromExtension(objectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String contentTypeFromExtension(String objectName) {
|
||||||
|
String lower = objectName.toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
if (lower.endsWith(".png")) {
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
if (lower.endsWith(".gif")) {
|
||||||
|
return "image/gif";
|
||||||
|
}
|
||||||
|
if (lower.endsWith(".webp")) {
|
||||||
|
return "image/webp";
|
||||||
|
}
|
||||||
|
if (lower.endsWith(".bmp")) {
|
||||||
|
return "image/bmp";
|
||||||
|
}
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StoredImage {
|
||||||
|
private final InputStream inputStream;
|
||||||
|
private final long size;
|
||||||
|
private final String contentType;
|
||||||
|
|
||||||
|
public StoredImage(InputStream inputStream, long size, String contentType) {
|
||||||
|
this.inputStream = inputStream;
|
||||||
|
this.size = size;
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InputStream getInputStream() {
|
||||||
|
return inputStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,18 @@ spring.flyway.schemas=public
|
|||||||
spring.flyway.baselineOnMigrate=true
|
spring.flyway.baselineOnMigrate=true
|
||||||
|
|
||||||
spring.mvc.dispatch-options-request=true
|
spring.mvc.dispatch-options-request=true
|
||||||
|
spring.servlet.multipart.max-file-size=10MB
|
||||||
|
spring.servlet.multipart.max-request-size=10MB
|
||||||
|
|
||||||
security.enabled=true
|
security.enabled=true
|
||||||
|
|
||||||
jwt.secret-key=change-me-in-production
|
jwt.secret-key=change-me-in-production
|
||||||
jwt.access-token-expire-minutes=1440
|
jwt.access-token-expire-minutes=1440
|
||||||
|
|
||||||
|
minio.endpoint=minio:9000
|
||||||
|
minio.access-key=minioadmin
|
||||||
|
minio.secret-key=ChangeMe_Minio_Strong_123456
|
||||||
|
minio.bucket=images
|
||||||
|
minio.secure=false
|
||||||
|
minio.local-fallback-enabled=true
|
||||||
|
minio.local-directory=uploads/images
|
||||||
|
|||||||
Reference in New Issue
Block a user