✨ Recycled files action
This commit is contained in:
@@ -8,8 +8,8 @@
|
|||||||
value-field="id"
|
value-field="id"
|
||||||
label-field="name"
|
label-field="name"
|
||||||
:placeholder="props.placeholder || 'Select a file pool to upload'"
|
:placeholder="props.placeholder || 'Select a file pool to upload'"
|
||||||
|
:size="props.size || 'large'"
|
||||||
clearable
|
clearable
|
||||||
size="large"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ import { formatBytes } from '@/views/format'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string | null
|
modelValue: string | null
|
||||||
placeholder?: string | undefined
|
placeholder?: string | undefined
|
||||||
|
size?: 'tiny' | 'small' | 'medium' | 'large' | undefined
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'update:pool'])
|
const emit = defineEmits(['update:modelValue', 'update:pool'])
|
||||||
|
@@ -1,12 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="h-full px-5 py-4">
|
<section class="h-full px-5 py-4">
|
||||||
<div class="flex flex-col gap-3 mb-3">
|
<div class="flex items-center gap-4 mb-3">
|
||||||
<file-pool-select
|
<file-pool-select
|
||||||
v-model="filePool"
|
v-model="filePool"
|
||||||
placeholder="Filter by file pool"
|
placeholder="Filter by file pool"
|
||||||
|
size="medium"
|
||||||
class="max-w-[480px]"
|
class="max-w-[480px]"
|
||||||
@update:pool="fetchFiles"
|
@update:pool="fetchFiles"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<n-switch size="large" v-model:value="showRecycled">
|
||||||
|
<template #checked>Recycled</template>
|
||||||
|
<template #unchecked>Unrecycled</template>
|
||||||
|
</n-switch>
|
||||||
|
<n-button
|
||||||
|
@click="askDeleteRecycledFiles"
|
||||||
|
v-if="showRecycled"
|
||||||
|
type="error"
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<n-icon>
|
||||||
|
<delete-sweep-round />
|
||||||
|
</n-icon>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
remote
|
remote
|
||||||
@@ -32,6 +50,8 @@ import {
|
|||||||
useDialog,
|
useDialog,
|
||||||
useMessage,
|
useMessage,
|
||||||
useLoadingBar,
|
useLoadingBar,
|
||||||
|
NSwitch,
|
||||||
|
NTooltip,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
AudioFileRound,
|
AudioFileRound,
|
||||||
@@ -39,8 +59,9 @@ import {
|
|||||||
VideoFileRound,
|
VideoFileRound,
|
||||||
FileDownloadOutlined,
|
FileDownloadOutlined,
|
||||||
DeleteRound,
|
DeleteRound,
|
||||||
|
DeleteSweepRound,
|
||||||
} from '@vicons/material'
|
} from '@vicons/material'
|
||||||
import { h, onMounted, ref } from 'vue'
|
import { h, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { formatBytes } from '../format'
|
import { formatBytes } from '../format'
|
||||||
import FilePoolSelect from '@/components/FilePoolSelect.vue'
|
import FilePoolSelect from '@/components/FilePoolSelect.vue'
|
||||||
@@ -48,7 +69,9 @@ import FilePoolSelect from '@/components/FilePoolSelect.vue'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const files = ref<any[]>([])
|
const files = ref<any[]>([])
|
||||||
|
|
||||||
const filePool = ref<string | null>(null)
|
const filePool = ref<string | null>(null)
|
||||||
|
const showRecycled = ref(false)
|
||||||
|
|
||||||
const tableColumns: DataTableColumns<any> = [
|
const tableColumns: DataTableColumns<any> = [
|
||||||
{
|
{
|
||||||
@@ -98,6 +121,28 @@ const tableColumns: DataTableColumns<any> = [
|
|||||||
return formatBytes(row.size)
|
return formatBytes(row.size)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Pool',
|
||||||
|
key: 'pool',
|
||||||
|
render(row: any) {
|
||||||
|
return h(
|
||||||
|
NTooltip,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
default: () => h('span', row.pool.id),
|
||||||
|
trigger: () => h('span', row.pool.name),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Expired At',
|
||||||
|
key: 'expired_at',
|
||||||
|
render(row: any) {
|
||||||
|
if (!row.expired_at) return 'Keep-alive'
|
||||||
|
return new Date(row.expired_at).toLocaleString()
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Uploaded At',
|
title: 'Uploaded At',
|
||||||
key: 'created_at',
|
key: 'created_at',
|
||||||
@@ -156,7 +201,7 @@ async function fetchFiles() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
const pag = tablePagination.value
|
const pag = tablePagination.value
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/files/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}${filePool.value ? '&pool=' + filePool.value : ''}`,
|
`/api/files/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}&recycled=${showRecycled.value}${filePool.value ? '&pool=' + filePool.value : ''}`,
|
||||||
)
|
)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Network response was not ok')
|
throw new Error('Network response was not ok')
|
||||||
@@ -172,6 +217,12 @@ async function fetchFiles() {
|
|||||||
}
|
}
|
||||||
onMounted(() => fetchFiles())
|
onMounted(() => fetchFiles())
|
||||||
|
|
||||||
|
watch(showRecycled, () => {
|
||||||
|
tablePagination.value.itemCount = 0
|
||||||
|
tablePagination.value.page = 1
|
||||||
|
fetchFiles()
|
||||||
|
})
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
tablePagination.value.page = page
|
tablePagination.value.page = page
|
||||||
fetchFiles()
|
fetchFiles()
|
||||||
@@ -183,10 +234,10 @@ const dialog = useDialog()
|
|||||||
const messageDialog = useMessage()
|
const messageDialog = useMessage()
|
||||||
const loadingBar = useLoadingBar()
|
const loadingBar = useLoadingBar()
|
||||||
|
|
||||||
async function askDeleteFile(file: any) {
|
function askDeleteFile(file: any) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: 'Confirm',
|
title: 'Confirm',
|
||||||
content: `Are you sure you want delete ${file.name}?`,
|
content: `Are you sure you want delete ${file.name}? This will delete the stored file data immediately, there is no return.`,
|
||||||
positiveText: 'Sure',
|
positiveText: 'Sure',
|
||||||
negativeText: 'Not Sure',
|
negativeText: 'Not Sure',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
@@ -214,4 +265,37 @@ async function deleteFile(file: any) {
|
|||||||
messageDialog.error('Failed to delete file: ' + (error as Error).message)
|
messageDialog.error('Failed to delete file: ' + (error as Error).message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askDeleteRecycledFiles() {
|
||||||
|
dialog.warning({
|
||||||
|
title: 'Confirm',
|
||||||
|
content: `Are you sure you want to delete all ${tablePagination.value.itemCount} marked recycled file(s) by system?`,
|
||||||
|
positiveText: 'Sure',
|
||||||
|
negativeText: 'Not Sure',
|
||||||
|
draggable: true,
|
||||||
|
onPositiveClick: () => {
|
||||||
|
deleteRecycledFiles()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecycledFiles() {
|
||||||
|
try {
|
||||||
|
loadingBar.start()
|
||||||
|
const response = await fetch('/api/files/me/recycle', {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok')
|
||||||
|
}
|
||||||
|
const resp = await response.json()
|
||||||
|
tablePagination.value.page = 1
|
||||||
|
await fetchFiles()
|
||||||
|
loadingBar.finish()
|
||||||
|
messageDialog.success(`Recycled files deleted successfully, deleted count: ${resp.count}`)
|
||||||
|
} catch (error) {
|
||||||
|
loadingBar.error()
|
||||||
|
messageDialog.error('Failed to delete recycled files: ' + (error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -136,6 +137,7 @@ public class FileController(
|
|||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
|
public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
|
||||||
[FromQuery] Guid? pool,
|
[FromQuery] Guid? pool,
|
||||||
|
[FromQuery] bool recycled = false,
|
||||||
[FromQuery] int offset = 0,
|
[FromQuery] int offset = 0,
|
||||||
[FromQuery] int take = 20
|
[FromQuery] int take = 20
|
||||||
)
|
)
|
||||||
@@ -144,6 +146,7 @@ public class FileController(
|
|||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
var query = db.Files
|
var query = db.Files
|
||||||
|
.Where(e => e.IsMarkedRecycle == recycled)
|
||||||
.Where(e => e.AccountId == accountId)
|
.Where(e => e.AccountId == accountId)
|
||||||
.Include(e => e.Pool)
|
.Include(e => e.Pool)
|
||||||
.OrderByDescending(e => e.CreatedAt)
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
@@ -182,4 +185,24 @@ public class FileController(
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpDelete("me/recycle")]
|
||||||
|
public async Task<ActionResult> DeleteMyRecycledFiles()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var count = await fs.DeleteAccountRecycledFilesAsync(accountId);
|
||||||
|
return Ok(new { Count = count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpDelete("recycle")]
|
||||||
|
[RequiredPermission("maintenance", "files.delete.recycle")]
|
||||||
|
public async Task<ActionResult> DeleteAllRecycledFiles()
|
||||||
|
{
|
||||||
|
var count = await fs.DeleteAllRecycledFilesAsync();
|
||||||
|
return Ok(new { Count = count });
|
||||||
|
}
|
||||||
}
|
}
|
@@ -7,7 +7,7 @@ namespace DysonNetwork.Drive.Storage;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/pools")]
|
[Route("/api/pools")]
|
||||||
public class FilePoolController(AppDatabase db) : ControllerBase
|
public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -28,4 +28,19 @@ public class FilePoolController(AppDatabase db) : ControllerBase
|
|||||||
|
|
||||||
return Ok(pools);
|
return Ok(pools);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpDelete("{id:guid}/recycle")]
|
||||||
|
public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var pool = await fs.GetPoolAsync(id);
|
||||||
|
if (pool is null) return NotFound();
|
||||||
|
if (!currentUser.IsSuperuser && pool.AccountId != accountId) return Unauthorized();
|
||||||
|
|
||||||
|
var count = await fs.DeletePoolRecycledFilesAsync(id);
|
||||||
|
return Ok(new { Count = count });
|
||||||
|
}
|
||||||
}
|
}
|
@@ -370,7 +370,7 @@ public class FileService(
|
|||||||
|
|
||||||
if (File.Exists(thumbnailPath))
|
if (File.Exists(thumbnailPath))
|
||||||
{
|
{
|
||||||
uploads.Add((thumbnailPath, ".thumbnail.webp", "image/webp", true));
|
uploads.Add((thumbnailPath, ".thumbnail", "image/webp", true));
|
||||||
hasThumbnail = true;
|
hasThumbnail = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -544,11 +544,11 @@ public class FileService(
|
|||||||
db.Remove(file);
|
db.Remove(file);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await _PurgeCacheAsync(file.Id);
|
await _PurgeCacheAsync(file.Id);
|
||||||
|
|
||||||
await DeleteFileDataAsync(file);
|
await DeleteFileDataAsync(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteFileDataAsync(CloudFile file)
|
private async Task DeleteFileDataAsync(CloudFile file)
|
||||||
{
|
{
|
||||||
if (file.StorageId is null) return;
|
if (file.StorageId is null) return;
|
||||||
if (!file.PoolId.HasValue) return;
|
if (!file.PoolId.HasValue) return;
|
||||||
@@ -581,7 +581,6 @@ public class FileService(
|
|||||||
|
|
||||||
if (file.HasCompression)
|
if (file.HasCompression)
|
||||||
{
|
{
|
||||||
// Also remove the compressed version if it exists
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await client.RemoveObjectAsync(
|
await client.RemoveObjectAsync(
|
||||||
@@ -594,6 +593,20 @@ public class FileService(
|
|||||||
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (file.HasThumbnail)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.RemoveObjectAsync(
|
||||||
|
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore errors when deleting thumbnail
|
||||||
|
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FilePool?> GetPoolAsync(Guid destination)
|
public async Task<FilePool?> GetPoolAsync(Guid destination)
|
||||||
@@ -735,6 +748,51 @@ public class FileService(
|
|||||||
if (fieldName.EndsWith("-data")) return true;
|
if (fieldName.EndsWith("-data")) return true;
|
||||||
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
|
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
|
||||||
|
{
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => f.AccountId == accountId && f.IsMarkedRecycle)
|
||||||
|
.ToListAsync();
|
||||||
|
var count = files.Count;
|
||||||
|
var tasks = files.Select(DeleteFileDataAsync);
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
var fileIds = files.Select(f => f.Id).ToList();
|
||||||
|
await _PurgeCacheRangeAsync(fileIds);
|
||||||
|
db.RemoveRange(files);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
|
||||||
|
{
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => f.PoolId == poolId && f.IsMarkedRecycle)
|
||||||
|
.ToListAsync();
|
||||||
|
var count = files.Count;
|
||||||
|
var tasks = files.Select(DeleteFileDataAsync);
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
var fileIds = files.Select(f => f.Id).ToList();
|
||||||
|
await _PurgeCacheRangeAsync(fileIds);
|
||||||
|
db.RemoveRange(files);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteAllRecycledFilesAsync()
|
||||||
|
{
|
||||||
|
var files = await db.Files
|
||||||
|
.Where(f => f.IsMarkedRecycle)
|
||||||
|
.ToListAsync();
|
||||||
|
var count = files.Count;
|
||||||
|
var tasks = files.Select(DeleteFileDataAsync);
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
var fileIds = files.Select(f => f.Id).ToList();
|
||||||
|
await _PurgeCacheRangeAsync(fileIds);
|
||||||
|
db.RemoveRange(files);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
Reference in New Issue
Block a user