mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 21:29:08 +03:00
feat(storage): replace a file from the preview panel
Overwrites an object in place, which on a versioned bucket is what creates a new version. Not `uploadFiles`, which renames rather than overwrites on a name clash, and with upsert set so storage does not reject the path as a conflict. The panel's own preview URL is cached for a week against the path, so the replace invalidates it alongside the version list; without that the panel keeps rendering the bytes from before. A rejected write reports why where storage answers with a bare status and no message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
This commit is contained in:
9 files changed
+337
-11
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { toast } from 'sonner'
|
||||
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
|
||||
import { getStorageItemPath } from './StorageExplorer.utils'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
import { useObjectPurgeMutation } from '@/data/storage/versioning/object-purge-mutation'
|
||||
import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
|
||||
@@ -33,11 +33,7 @@ export const ConfirmPurgeModal = () => {
|
||||
if (!projectRef || !selectedBucket?.id || itemToPurge === undefined) return
|
||||
|
||||
// The endpoint addresses an object by full path; a row only knows its leaf name.
|
||||
const folderPath = getPathAlongOpenedFolders(
|
||||
{ openedFolders: openedFolders.slice(0, itemToPurge.columnIndex), selectedBucket },
|
||||
false
|
||||
)
|
||||
const path = [folderPath, itemToPurge.name].filter(Boolean).join('/')
|
||||
const path = getStorageItemPath({ openedFolders }, itemToPurge)
|
||||
|
||||
purgeObject({ projectRef, bucketId: selectedBucket.id, path })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FilePreview } from '@/components/interfaces/Storage/StorageExplorer/FilePreview'
|
||||
import { fileUrlKey } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
|
||||
import { customRender as render } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
@@ -65,6 +66,36 @@ describe('FilePreview', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keys each version separately, so a new one cannot reuse the cached URL', () => {
|
||||
const current = fileUrlKey({
|
||||
projectRef: 'abcdef',
|
||||
isBucketPublic: false,
|
||||
bucketId: 'my-bucket',
|
||||
path: 'photo.png',
|
||||
})
|
||||
|
||||
expect(
|
||||
fileUrlKey({
|
||||
projectRef: 'abcdef',
|
||||
isBucketPublic: false,
|
||||
bucketId: 'my-bucket',
|
||||
path: 'photo.png',
|
||||
versionId: 'v-new',
|
||||
})
|
||||
).not.toEqual(current)
|
||||
|
||||
// The version-less key is a prefix of every version's, so one invalidation clears all.
|
||||
expect(
|
||||
fileUrlKey({
|
||||
projectRef: 'abcdef',
|
||||
isBucketPublic: false,
|
||||
bucketId: 'my-bucket',
|
||||
path: 'photo.png',
|
||||
versionId: 'v-new',
|
||||
}).slice(0, current.length)
|
||||
).toEqual(current)
|
||||
})
|
||||
|
||||
it('skips the request for a file too large to preview', async () => {
|
||||
const bodies = mockSignEndpoint()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertCircle, Archive, ChevronDown, Copy, Download, Trash2, X } from 'lucide-react'
|
||||
import { AlertCircle, Archive, ChevronDown, Copy, Download, Trash2, Upload, X } from 'lucide-react'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -18,6 +18,7 @@ import { URL_EXPIRY_DURATION } from '../Storage.constants'
|
||||
import { StorageItem } from '../Storage.types'
|
||||
import { getBucketVersioningState } from '../StorageVersioning.constants'
|
||||
import { FilePreview } from './FilePreview'
|
||||
import { pickFile } from './pickFile'
|
||||
import { PreviewSection } from './PreviewSection'
|
||||
import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
|
||||
import { useStorageExplorerNavigation } from './StorageExplorerNavigation'
|
||||
@@ -65,10 +66,13 @@ interface CurrentFilePreviewProps {
|
||||
size: string | null
|
||||
isPublicBucket: boolean
|
||||
isVersionedBucket: boolean
|
||||
/** Pins the preview to the current version, so a new one busts the cached URL. */
|
||||
currentVersionId?: string
|
||||
hasCurrentVersion: boolean
|
||||
canUpdateFiles: boolean
|
||||
onCopyUrl: (path: string, expiry?: number) => void
|
||||
onDownload: () => void
|
||||
onReplace: () => void
|
||||
onCustomExpiry: () => void
|
||||
onDelete: () => void
|
||||
onPurge: () => void
|
||||
@@ -81,10 +85,12 @@ const CurrentFilePreview = ({
|
||||
size,
|
||||
isPublicBucket,
|
||||
isVersionedBucket,
|
||||
currentVersionId,
|
||||
hasCurrentVersion,
|
||||
canUpdateFiles,
|
||||
onCopyUrl,
|
||||
onDownload,
|
||||
onReplace,
|
||||
onCustomExpiry,
|
||||
onDelete,
|
||||
onPurge,
|
||||
@@ -94,7 +100,12 @@ const CurrentFilePreview = ({
|
||||
className="flex items-center justify-center overflow-hidden rounded-md border border-overlay"
|
||||
style={{ height: 'clamp(120px, calc((100vh - 144px) * 0.4), 180px)' }}
|
||||
>
|
||||
<FilePreview path={path} mimeType={mimeType} size={file.metadata?.size} />
|
||||
<FilePreview
|
||||
path={path}
|
||||
mimeType={mimeType}
|
||||
size={file.metadata?.size}
|
||||
versionId={currentVersionId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col">
|
||||
@@ -129,6 +140,21 @@ const CurrentFilePreview = ({
|
||||
tooltip={{ content: { side: 'top', text: 'Download current' } }}
|
||||
/>
|
||||
|
||||
{canUpdateFiles && (
|
||||
<ButtonTooltip
|
||||
variant="outline"
|
||||
className="px-2"
|
||||
icon={<Upload size={14} />}
|
||||
onClick={onReplace}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'top',
|
||||
text: isVersionedBucket ? 'Upload new version' : 'Replace file',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPublicBucket ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -233,6 +259,7 @@ export const PreviewPane = () => {
|
||||
setSelectedItemsToDelete,
|
||||
setItemToPurge,
|
||||
refetchAllOpenedFolders,
|
||||
replaceFile,
|
||||
setSelectedFileCustomExpiry,
|
||||
downloadFile,
|
||||
} = useStorageExplorerStateSnapshot()
|
||||
@@ -305,6 +332,11 @@ export const PreviewPane = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const onReplaceFile = async () => {
|
||||
const newFile = await pickFile()
|
||||
if (newFile !== undefined) await replaceFile({ file: newFile, item: file })
|
||||
}
|
||||
|
||||
// The compare widget replaces the top of the panel, so scroll up to show it.
|
||||
const handlePreviewVersion = (version: ObjectVersion) => {
|
||||
setPreviewedVersion(version)
|
||||
@@ -348,10 +380,12 @@ export const PreviewPane = () => {
|
||||
size={size}
|
||||
isPublicBucket={!!selectedBucket?.public}
|
||||
isVersionedBucket={isVersionedBucket}
|
||||
currentVersionId={currentVersion?.versionId}
|
||||
hasCurrentVersion={currentVersion !== undefined}
|
||||
canUpdateFiles={canUpdateFiles}
|
||||
onCopyUrl={onCopyUrl}
|
||||
onDownload={() => downloadFile(file)}
|
||||
onReplace={onReplaceFile}
|
||||
onCustomExpiry={() => setSelectedFileCustomExpiry(file)}
|
||||
onDelete={() => setSelectedItemsToDelete([file])}
|
||||
onPurge={() => setItemToPurge(file)}
|
||||
|
||||
+56
@@ -10,9 +10,11 @@ import type { StorageItem } from '@/components/interfaces/Storage/Storage.types'
|
||||
import {
|
||||
copyStorageExplorerUrl,
|
||||
copyStoragePath,
|
||||
describeUploadFailure,
|
||||
getPathAlongFoldersToIndex,
|
||||
getPathAlongOpenedFolders,
|
||||
getStorageExplorerUrlForItem,
|
||||
getStorageItemPath,
|
||||
getStoragePathForItem,
|
||||
parseStoragePath,
|
||||
sanitizeNameForDuplicateInColumn,
|
||||
@@ -452,3 +454,57 @@ describe('clipboard helpers', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Copied URL for "photo.png"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStorageItemPath', () => {
|
||||
const openedFolders = [makeFolder('images'), makeFolder('2024'), makeFolder('january')]
|
||||
|
||||
it('returns the bare name for an item at the bucket root', () => {
|
||||
expect(getStorageItemPath({ openedFolders }, { name: 'avatar.png', columnIndex: 0 })).toBe(
|
||||
'avatar.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefixes the folders opened above the item', () => {
|
||||
expect(getStorageItemPath({ openedFolders }, { name: 'avatar.png', columnIndex: 2 })).toBe(
|
||||
'images/2024/avatar.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores folders opened below the item', () => {
|
||||
// The user drilled into january, but the item sits in the 2024 column.
|
||||
expect(getStorageItemPath({ openedFolders }, { name: 'notes.txt', columnIndex: 1 })).toBe(
|
||||
'images/notes.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeUploadFailure', () => {
|
||||
it('names the allowed types when the bucket rejects the mime type', () => {
|
||||
expect(
|
||||
describeUploadFailure({
|
||||
status: 415,
|
||||
fallback: 'tus: unexpected response',
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
})
|
||||
).toBe(
|
||||
'that file type is not allowed in this bucket. Allowed MIME types: image/png, image/jpeg.'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the list when the bucket allows everything', () => {
|
||||
expect(describeUploadFailure({ status: 415, fallback: 'tus: unexpected response' })).toBe(
|
||||
'that file type is not allowed in this bucket.'
|
||||
)
|
||||
})
|
||||
|
||||
it('explains a size rejection', () => {
|
||||
expect(describeUploadFailure({ status: 413, fallback: 'tus: unexpected response' })).toBe(
|
||||
'the file exceeds the bucket file size limit.'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the raw message for anything else', () => {
|
||||
expect(describeUploadFailure({ status: 500, fallback: 'network down' })).toBe('network down')
|
||||
expect(describeUploadFailure({ fallback: 'network down' })).toBe('network down')
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,15 @@ export function getPathAlongOpenedFolders(
|
||||
* Returns the path to the folder at the given index in the openedFolders array,
|
||||
* joining all folders from the root up to (but not including) the given index.
|
||||
*/
|
||||
/** The object's full path within the bucket; a row only knows its leaf name and column. */
|
||||
export function getStorageItemPath(
|
||||
state: Pick<StorageExplorerState, 'openedFolders'>,
|
||||
item: { name: string; columnIndex: number }
|
||||
): string {
|
||||
const folderPath = getPathAlongFoldersToIndex(state, item.columnIndex)
|
||||
return folderPath.length > 0 ? `${folderPath}/${item.name}` : item.name
|
||||
}
|
||||
|
||||
export function getPathAlongFoldersToIndex(
|
||||
state: Pick<StorageExplorerState, 'openedFolders'>,
|
||||
index: number
|
||||
@@ -316,3 +325,26 @@ const readEntriesPromise = async (directoryReader: FileSystemDirectoryReader) =>
|
||||
console.error('readEntriesPromise error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage rejects a write that breaks the bucket's own rules with a bare status and no
|
||||
* usable message, so turn the ones a replace can hit into something actionable.
|
||||
*/
|
||||
export const describeUploadFailure = ({
|
||||
status,
|
||||
fallback,
|
||||
allowedMimeTypes,
|
||||
}: {
|
||||
status?: number
|
||||
fallback: string
|
||||
allowedMimeTypes?: string[] | null
|
||||
}) => {
|
||||
if (status === 415) {
|
||||
const allowed = allowedMimeTypes?.length
|
||||
? ` Allowed MIME types: ${allowedMimeTypes.join(', ')}.`
|
||||
: ''
|
||||
return `that file type is not allowed in this bucket.${allowed}`
|
||||
}
|
||||
if (status === 413) return 'the file exceeds the bucket file size limit.'
|
||||
return fallback
|
||||
}
|
||||
@@ -62,8 +62,8 @@ const VersionFateLabel = ({ fate }: { fate: VersionFate }) => {
|
||||
Queued to expire
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-64">
|
||||
Matches the lifecycle policy and will be permanently deleted, which can take up to 24
|
||||
hours.
|
||||
Matches the lifecycle policy and will be permanently deleted. This action can take up to
|
||||
24 hours.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { pickFile } from './pickFile'
|
||||
|
||||
/** Captures the input `pickFile` creates, and whether `click()` happened synchronously. */
|
||||
const interceptInput = () => {
|
||||
const created = { input: undefined as HTMLInputElement | undefined, clickedSync: false }
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||
const element = realCreateElement(tagName)
|
||||
if (tagName === 'input') {
|
||||
created.input = element as HTMLInputElement
|
||||
element.click = () => {
|
||||
created.clickedSync = true
|
||||
}
|
||||
}
|
||||
return element
|
||||
})
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
const setFiles = (input: HTMLInputElement, files: File[]) => {
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
}
|
||||
|
||||
describe('pickFile', () => {
|
||||
it('opens the picker synchronously, so the user gesture is not lost', () => {
|
||||
const created = interceptInput()
|
||||
|
||||
void pickFile()
|
||||
|
||||
expect(created.clickedSync).toBe(true)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves with the chosen file', async () => {
|
||||
const created = interceptInput()
|
||||
const pending = pickFile()
|
||||
const file = new File(['hello'], 'avatar.png', { type: 'image/png' })
|
||||
|
||||
setFiles(created.input!, [file])
|
||||
created.input!.dispatchEvent(new Event('change'))
|
||||
|
||||
await expect(pending).resolves.toBe(file)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves undefined when the picker is dismissed', async () => {
|
||||
const created = interceptInput()
|
||||
const pending = pickFile()
|
||||
|
||||
created.input!.dispatchEvent(new Event('cancel'))
|
||||
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves undefined when the picker closes with no selection', async () => {
|
||||
const created = interceptInput()
|
||||
const pending = pickFile()
|
||||
|
||||
setFiles(created.input!, [])
|
||||
created.input!.dispatchEvent(new Event('change'))
|
||||
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('passes an accept filter through to the input', () => {
|
||||
const created = interceptInput()
|
||||
|
||||
void pickFile({ accept: 'image/*' })
|
||||
|
||||
expect(created.input!.accept).toBe('image/*')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Created and clicked synchronously to stay inside the user gesture, which Safari
|
||||
* requires — so no hidden input driven from an effect.
|
||||
*/
|
||||
export const pickFile = ({ accept }: { accept?: string } = {}): Promise<File | undefined> =>
|
||||
new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
if (accept) input.accept = accept
|
||||
|
||||
input.addEventListener('change', () => resolve(input.files?.[0]), { once: true })
|
||||
// Not supported everywhere, so this is a cleanup path rather than the one resolve needs.
|
||||
input.addEventListener('cancel', () => resolve(undefined), { once: true })
|
||||
|
||||
input.click()
|
||||
})
|
||||
@@ -32,15 +32,20 @@ import {
|
||||
} from '@/components/interfaces/Storage/Storage.types'
|
||||
import {
|
||||
calculateTotalRemainingTime,
|
||||
describeUploadFailure,
|
||||
EMPTY_FOLDER_PLACEHOLDER_FILE_NAME,
|
||||
formatFolderItems,
|
||||
formatTime,
|
||||
getFilesDataTransferItems,
|
||||
getPathAlongFoldersToIndex,
|
||||
getStorageItemPath,
|
||||
sanitizeNameForDuplicateInColumn,
|
||||
validateFolderName,
|
||||
} from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils'
|
||||
import { fetchFileUrl } from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
|
||||
import {
|
||||
fetchFileUrl,
|
||||
fileUrlKey,
|
||||
} from '@/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery'
|
||||
import { getStoragePreference } from '@/components/interfaces/Storage/StorageExplorer/useStoragePreference'
|
||||
import { convertFromBytes } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
@@ -54,6 +59,7 @@ import { signBucketObjects } from '@/data/storage/bucket-object-sign-mutation'
|
||||
import { listBucketObjects, StorageObject } from '@/data/storage/bucket-objects-list-mutation'
|
||||
import { deleteBucketPrefix } from '@/data/storage/bucket-prefix-delete-mutation'
|
||||
import type { Bucket } from '@/data/storage/buckets-query'
|
||||
import { storageKeys } from '@/data/storage/keys'
|
||||
import { moveStorageObject } from '@/data/storage/object-move-mutation'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from '@/lib/constants'
|
||||
@@ -1503,6 +1509,82 @@ export function createStorageExplorerState({
|
||||
state.setSelectedItemsToMove([])
|
||||
},
|
||||
|
||||
/**
|
||||
* Overwrites an object in place, which on a versioned bucket is what creates a new
|
||||
* version. Not `uploadFiles`: that renames rather than overwrites on a name clash.
|
||||
*/
|
||||
replaceFile: async ({ file, item }: { file: File; item: StorageItemWithColumn }) => {
|
||||
const path = getStorageItemPath(state, item)
|
||||
const toastId = toast.loading(`Uploading a new version of ${item.name}...`)
|
||||
|
||||
state.updateRowStatus({
|
||||
name: item.name,
|
||||
status: STORAGE_ROW_STATUS.LOADING,
|
||||
columnIndex: item.columnIndex,
|
||||
})
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: state.resumableUploadUrl,
|
||||
retryDelays: [0, 200, 500, 1500, 3000, 5000],
|
||||
// Without upsert the write is rejected as a conflict: the path exists.
|
||||
headers: { 'x-source': 'supabase-dashboard', 'x-upsert': 'true' },
|
||||
uploadDataDuringCreation: true,
|
||||
removeFingerprintOnSuccess: true,
|
||||
metadata: {
|
||||
bucketName: state.selectedBucket.name,
|
||||
objectName: path,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
cacheControl: '3600',
|
||||
},
|
||||
onBeforeRequest: async (req) => {
|
||||
const { apiKey } = await getOrRefreshTemporaryApiKey(state.projectRef)
|
||||
req.setHeader('apikey', apiKey)
|
||||
if (!IS_PLATFORM) req.setHeader('Authorization', `Bearer ${apiKey}`)
|
||||
},
|
||||
onError: (error) => reject(error),
|
||||
onSuccess: () => resolve(),
|
||||
})
|
||||
|
||||
// No `findPreviousUploads`: a resumed upload would write the wrong file's bytes.
|
||||
upload.start()
|
||||
})
|
||||
|
||||
toast.success(`Uploaded a new version of ${item.name}`, { id: toastId })
|
||||
await state.refetchAllOpenedFolders()
|
||||
await Promise.all([
|
||||
getQueryClient().invalidateQueries({
|
||||
queryKey: storageKeys.objectVersions(state.projectRef, state.selectedBucket.id, path),
|
||||
}),
|
||||
// The preview URL is cached for a week against the path, so without this the
|
||||
// panel keeps rendering the bytes from before the replace.
|
||||
getQueryClient().invalidateQueries({
|
||||
queryKey: fileUrlKey({
|
||||
projectRef: state.projectRef,
|
||||
isBucketPublic: state.selectedBucket.public,
|
||||
bucketId: state.selectedBucket.id,
|
||||
path,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
const status =
|
||||
error instanceof tus.DetailedError ? error.originalResponse?.getStatus() : undefined
|
||||
const reason = describeUploadFailure({
|
||||
status,
|
||||
fallback: error instanceof Error ? error.message : 'the upload failed.',
|
||||
allowedMimeTypes: state.selectedBucket.allowed_mime_types,
|
||||
})
|
||||
toast.error(`Failed to upload a new version of ${item.name}: ${reason}`, { id: toastId })
|
||||
state.updateRowStatus({
|
||||
name: item.name,
|
||||
status: STORAGE_ROW_STATUS.READY,
|
||||
columnIndex: item.columnIndex,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
deleteFiles: async ({
|
||||
files,
|
||||
isDeleteFolder = false,
|
||||
|
||||
Reference in new issue
Block a user