Storage: Optionally overwrite existing files (#52067)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Adela Almasan 2022-07-12 09:13:57 -05:00 committed by GitHub
parent 2090534635
commit 2f942c57e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 58 additions and 18 deletions

View File

@ -169,7 +169,9 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender
let errors: string[] = []; let errors: string[] = [];
rejectedFiles.map((rejectedFile) => { rejectedFiles.map((rejectedFile) => {
rejectedFile.errors.map((error) => { rejectedFile.errors.map((error) => {
errors.push(error.message); if (errors.indexOf(error.message) === -1) {
errors.push(error.message);
}
}); });
}); });

View File

@ -75,13 +75,14 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
}) })
} }
folder, ok := c.Req.MultipartForm.Value["folder"] folder, ok := getMultipartFormValue(c.Req, "folder")
if !ok || len(folder) != 1 { if !ok || folder == "" {
return response.JSON(400, map[string]interface{}{ return response.JSON(400, map[string]interface{}{
"message": "please specify the upload folder", "message": "please specify the upload folder",
"err": true, "err": true,
}) })
} }
overwriteExistingFile, _ := getMultipartFormValue(c.Req, "overwriteExistingFile")
fileHeader := files[0] fileHeader := files[0]
if fileHeader.Size > MAX_UPLOAD_SIZE { if fileHeader.Size > MAX_UPLOAD_SIZE {
@ -107,7 +108,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
return errFileTooBig return errFileTooBig
} }
path := folder[0] + "/" + fileHeader.Filename path := folder + "/" + fileHeader.Filename
mimeType := http.DetectContentType(data) mimeType := http.DetectContentType(data)
@ -116,7 +117,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
MimeType: mimeType, MimeType: mimeType,
EntityType: EntityTypeImage, EntityType: EntityTypeImage,
Path: path, Path: path,
OverwriteExistingFile: true, OverwriteExistingFile: overwriteExistingFile == "true",
}) })
if err != nil { if err != nil {
@ -131,6 +132,14 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
}) })
} }
func getMultipartFormValue(req *http.Request, key string) (string, bool) {
v, ok := req.MultipartForm.Value[key]
if !ok || len(v) != 1 {
return "", false
}
return v[0], ok
}
func (s *httpStorage) Read(c *models.ReqContext) response.Response { func (s *httpStorage) Read(c *models.ReqContext) response.Response {
// full path is api/storage/read/upload/example.jpg, but we only want the part after read // full path is api/storage/read/upload/example.jpg, but we only want the part after read
scope, path := getPathAndScope(c) scope, path := getPathAndScope(c)

View File

@ -13,9 +13,10 @@ interface Props {
path: string; path: string;
onPathChange: (p: string, view?: StorageView) => void; onPathChange: (p: string, view?: StorageView) => void;
view: StorageView; view: StorageView;
fileNames: string[];
} }
export function FolderView({ listing, path, onPathChange, view }: Props) { export function FolderView({ listing, path, onPathChange, view, fileNames }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
switch (view) { switch (view) {
@ -35,6 +36,7 @@ export function FolderView({ listing, path, onPathChange, view }: Props) {
onPathChange(path); // back to data onPathChange(path); // back to data
} }
}} }}
fileNames={fileNames}
/> />
); );
} }

View File

@ -18,7 +18,7 @@ import { ExportView } from './ExportView';
import { FileView } from './FileView'; import { FileView } from './FileView';
import { FolderView } from './FolderView'; import { FolderView } from './FolderView';
import { RootView } from './RootView'; import { RootView } from './RootView';
import { getGrafanaStorage } from './helper'; import { getGrafanaStorage, filenameAlreadyExists } from './helper';
import { StorageView } from './types'; import { StorageView } from './types';
interface RouteParams { interface RouteParams {
@ -211,7 +211,7 @@ export default function StoragePage(props: Props) {
))} ))}
</TabsBar> </TabsBar>
{isFolder ? ( {isFolder ? (
<FolderView path={path} listing={frame} onPathChange={setPath} view={view} /> <FolderView path={path} listing={frame} onPathChange={setPath} view={view} fileNames={fileNames} />
) : ( ) : (
<FileView path={path} listing={frame} onPathChange={setPath} view={view} /> <FileView path={path} listing={frame} onPathChange={setPath} view={view} />
)} )}
@ -231,10 +231,8 @@ export default function StoragePage(props: Props) {
}} }}
validate={(folderName) => { validate={(folderName) => {
const lowerCase = folderName.toLowerCase(); const lowerCase = folderName.toLowerCase();
const trimmedLowerCase = lowerCase.trim();
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
if (existingTrimmedLowerCaseNames.includes(trimmedLowerCase)) { if (filenameAlreadyExists(folderName, fileNames)) {
return 'A file or a folder with the same name already exists'; return 'A file or a folder with the same name already exists';
} }

View File

@ -3,14 +3,15 @@ import React, { useState } from 'react';
import SVG from 'react-inlinesvg'; import SVG from 'react-inlinesvg';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Button, ButtonGroup, Field, FileDropzone, useStyles2 } from '@grafana/ui'; import { Alert, Button, ButtonGroup, Checkbox, Field, FileDropzone, useStyles2 } from '@grafana/ui';
import { getGrafanaStorage } from './helper'; import { filenameAlreadyExists, getGrafanaStorage } from './helper';
import { UploadReponse } from './types'; import { UploadReponse } from './types';
interface Props { interface Props {
folder: string; folder: string;
onUpload: (rsp: UploadReponse) => void; onUpload: (rsp: UploadReponse) => void;
fileNames: string[];
} }
interface ErrorResponse { interface ErrorResponse {
@ -27,12 +28,13 @@ const FileDropzoneCustomChildren = ({ secondaryText = 'Drag and drop here or bro
); );
}; };
export const UploadView = ({ folder, onUpload }: Props) => { export const UploadView = ({ folder, onUpload, fileNames }: Props) => {
const [file, setFile] = useState<File | undefined>(undefined); const [file, setFile] = useState<File | undefined>(undefined);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [error, setError] = useState<ErrorResponse>({ message: '' }); const [error, setError] = useState<ErrorResponse>({ message: '' });
const [overwriteExistingFile, setOverwriteExistingFile] = useState(false);
const Preview = () => { const Preview = () => {
if (!file) { if (!file) {
@ -58,7 +60,7 @@ export const UploadView = ({ folder, onUpload }: Props) => {
return; return;
} }
const rsp = await getGrafanaStorage().upload(folder, file); const rsp = await getGrafanaStorage().upload(folder, file, overwriteExistingFile);
if (rsp.status !== 200) { if (rsp.status !== 200) {
setError(rsp); setError(rsp);
} else { } else {
@ -66,6 +68,9 @@ export const UploadView = ({ folder, onUpload }: Props) => {
} }
}; };
const filenameExists = file ? filenameAlreadyExists(file.name, fileNames) : false;
const isUploadDisabled = !file || (filenameExists && !overwriteExistingFile);
return ( return (
<div> <div>
<FileDropzone <FileDropzone
@ -84,8 +89,20 @@ export const UploadView = ({ folder, onUpload }: Props) => {
{error.message !== '' ? <p>{error.message}</p> : Boolean(file) ? <Preview /> : <FileDropzoneCustomChildren />} {error.message !== '' ? <p>{error.message}</p> : Boolean(file) ? <Preview /> : <FileDropzoneCustomChildren />}
</FileDropzone> </FileDropzone>
{file && filenameExists && (
<div className={styles.alert}>
<Alert title={`${file.name} already exists`} severity="error">
<Checkbox
value={overwriteExistingFile}
onChange={() => setOverwriteExistingFile(!overwriteExistingFile)}
label="Overwrite existing file"
/>
</Alert>
</div>
)}
<ButtonGroup> <ButtonGroup>
<Button className={styles.button} variant={'primary'} disabled={!file} onClick={doUpload}> <Button className={styles.button} variant={'primary'} disabled={isUploadDisabled} onClick={doUpload}>
Upload Upload
</Button> </Button>
</ButtonGroup> </ButtonGroup>
@ -133,4 +150,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
margin-bottom: ${theme.spacing(2)}; margin-bottom: ${theme.spacing(2)};
`, `,
alert: css`
padding-top: 10px;
`,
}); });

View File

@ -7,7 +7,7 @@ import { UploadReponse } from './types';
export interface GrafanaStorage { export interface GrafanaStorage {
get: <T = any>(path: string) => Promise<T>; get: <T = any>(path: string) => Promise<T>;
list: (path: string) => Promise<DataFrame | undefined>; list: (path: string) => Promise<DataFrame | undefined>;
upload: (folder: string, file: File) => Promise<UploadReponse>; upload: (folder: string, file: File, overwriteExistingFile: boolean) => Promise<UploadReponse>;
createFolder: (path: string) => Promise<{ error?: string }>; createFolder: (path: string) => Promise<{ error?: string }>;
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>; delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
} }
@ -82,10 +82,11 @@ class SimpleStorage implements GrafanaStorage {
return req.isFolder ? this.deleteFolder({ path: req.path, force: true }) : this.deleteFile({ path: req.path }); return req.isFolder ? this.deleteFolder({ path: req.path, force: true }) : this.deleteFile({ path: req.path });
} }
async upload(folder: string, file: File): Promise<UploadReponse> { async upload(folder: string, file: File, overwriteExistingFile: boolean): Promise<UploadReponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('folder', folder); formData.append('folder', folder);
formData.append('file', file); formData.append('file', file);
formData.append('overwriteExistingFile', String(overwriteExistingFile));
const res = await fetch('/api/storage/upload', { const res = await fetch('/api/storage/upload', {
method: 'POST', method: 'POST',
body: formData, body: formData,
@ -112,3 +113,11 @@ export function getGrafanaStorage() {
} }
return storage; return storage;
} }
export function filenameAlreadyExists(folderName: string, fileNames: string[]) {
const lowerCase = folderName.toLowerCase();
const trimmedLowerCase = lowerCase.trim();
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
return existingTrimmedLowerCaseNames.includes(trimmedLowerCase);
}