mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
Add directory download as zip feature
Implements the ability for users to download directories as zip files if enabled in config. Adds a new API route for directory zipping, updates UI components to show a download button for directories, and introduces related config and type changes. Also includes a new download icon.
This commit is contained in:
parent
c81ef77370
commit
5e913dd9d5
11 changed files with 317 additions and 4 deletions
|
|
@ -18,6 +18,7 @@ const config: PartialDeep<Config> = {
|
|||
// files: {
|
||||
// rootPath: 'data-files',
|
||||
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
|
||||
// allowDirectoryDownloads: true, // If true, directories can be downloaded as zip files
|
||||
// },
|
||||
// core: {
|
||||
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // dashboard and files cannot be disabled
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface ListFilesProps {
|
|||
onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickCreateShare?: (filePath: string) => void;
|
||||
onClickOpenManageShare?: (fileShareId: string) => void;
|
||||
onClickDownloadDirectory?: (parentPath: string, name: string) => void;
|
||||
isShowingNotes?: boolean;
|
||||
isShowingPhotos?: boolean;
|
||||
fileShareId?: string;
|
||||
|
|
@ -39,6 +40,7 @@ export default function ListFiles(
|
|||
onClickDeleteFile,
|
||||
onClickCreateShare,
|
||||
onClickOpenManageShare,
|
||||
onClickDownloadDirectory,
|
||||
isShowingNotes,
|
||||
isShowingPhotos,
|
||||
fileShareId,
|
||||
|
|
@ -165,10 +167,26 @@ export default function ListFiles(
|
|||
typeof onClickOpenMoveDirectory === 'undefined')
|
||||
? null
|
||||
: (
|
||||
<section class='flex items-center justify-end w-24'>
|
||||
<section class='flex items-center justify-end w-32'>
|
||||
{typeof onClickDownloadDirectory === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickDownloadDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/download.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Download directory as zip'
|
||||
title='Download directory as zip'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
onClick={() =>
|
||||
onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
|
|
@ -181,8 +199,7 @@ export default function ListFiles(
|
|||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() =>
|
||||
onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface MainFilesProps {
|
|||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isDirectoryDownloadsAllowed: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +59,7 @@ export default function MainFiles(
|
|||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
isDirectoryDownloadsAllowed,
|
||||
fileShareId,
|
||||
}: MainFilesProps,
|
||||
) {
|
||||
|
|
@ -411,6 +413,20 @@ export default function MainFiles(
|
|||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
function onClickDownloadDirectory(parentPath: string, name: string) {
|
||||
// Create download URL with proper path encoding
|
||||
const fullPath = parentPath + name + '/';
|
||||
const downloadUrl = `/api/files/download-directory?path=${encodeURIComponent(fullPath)}`;
|
||||
|
||||
// Create a temporary anchor element to trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `${name}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async function onClickDeleteDirectory(parentPath: string, name: string, isBulkDeleting = false) {
|
||||
if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) {
|
||||
if (!isBulkDeleting && isDeleting.value) {
|
||||
|
|
@ -839,6 +855,7 @@ export default function MainFiles(
|
|||
onClickDeleteFile={onClickDeleteFile}
|
||||
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
|
||||
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
|
||||
onClickDownloadDirectory={isDirectoryDownloadsAllowed ? onClickDownloadDirectory : undefined}
|
||||
fileShareId={fileShareId}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import * as $api_files_create_share from './routes/api/files/create-share.tsx';
|
|||
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
|
||||
import * as $api_files_delete_share from './routes/api/files/delete-share.tsx';
|
||||
import * as $api_files_delete from './routes/api/files/delete.tsx';
|
||||
import * as $api_files_download_directory from './routes/api/files/download-directory.tsx';
|
||||
import * as $api_files_get_directories from './routes/api/files/get-directories.tsx';
|
||||
import * as $api_files_get_share from './routes/api/files/get-share.tsx';
|
||||
import * as $api_files_get from './routes/api/files/get.tsx';
|
||||
|
|
@ -155,6 +156,7 @@ const manifest = {
|
|||
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
||||
'./routes/api/files/delete-share.tsx': $api_files_delete_share,
|
||||
'./routes/api/files/delete.tsx': $api_files_delete,
|
||||
'./routes/api/files/download-directory.tsx': $api_files_download_directory,
|
||||
'./routes/api/files/get-directories.tsx': $api_files_get_directories,
|
||||
'./routes/api/files/get-share.tsx': $api_files_get_share,
|
||||
'./routes/api/files/get.tsx': $api_files_get,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface FilesWrapperProps {
|
|||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isDirectoryDownloadsAllowed: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export default function FilesWrapper(
|
|||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
isDirectoryDownloadsAllowed,
|
||||
fileShareId,
|
||||
}: FilesWrapperProps,
|
||||
) {
|
||||
|
|
@ -28,6 +30,7 @@ export default function FilesWrapper(
|
|||
initialPath={initialPath}
|
||||
baseUrl={baseUrl}
|
||||
isFileSharingAllowed={isFileSharingAllowed}
|
||||
isDirectoryDownloadsAllowed={isDirectoryDownloadsAllowed}
|
||||
fileShareId={fileShareId}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export class AppConfig {
|
|||
files: {
|
||||
rootPath: 'data-files',
|
||||
allowPublicSharing: false,
|
||||
allowDirectoryDownloads: true,
|
||||
},
|
||||
core: {
|
||||
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
||||
|
|
@ -179,6 +180,12 @@ export class AppConfig {
|
|||
return this.config.files.allowPublicSharing;
|
||||
}
|
||||
|
||||
static async isDirectoryDownloadsAllowed(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.files.allowDirectoryDownloads;
|
||||
}
|
||||
|
||||
static async getFilesRootPath(): Promise<string> {
|
||||
await this.loadConfig();
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ export interface Config {
|
|||
rootPath: string;
|
||||
/** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */
|
||||
allowPublicSharing: boolean;
|
||||
/** If true, directories can be downloaded as zip files */
|
||||
allowDirectoryDownloads: boolean;
|
||||
};
|
||||
core: {
|
||||
/** dashboard and files cannot be disabled */
|
||||
|
|
|
|||
246
routes/api/files/download-directory.tsx
Normal file
246
routes/api/files/download-directory.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { Handlers } from 'fresh/server.ts';
|
||||
import { join, resolve } from '@std/path';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { DirectoryModel, FileModel } from '/lib/models/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const config = await AppConfig.getConfig();
|
||||
|
||||
// Check if directory downloads are enabled
|
||||
if (!config.files?.allowDirectoryDownloads) {
|
||||
return new Response('Directory downloads are not enabled', { status: 403 });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
let directoryPath = searchParams.get('path') || '/';
|
||||
|
||||
// Send invalid paths back to root
|
||||
if (!directoryPath.startsWith('/') || directoryPath.includes('../')) {
|
||||
return new Response('Invalid path', { status: 400 });
|
||||
}
|
||||
|
||||
// Always append a trailing slash
|
||||
if (!directoryPath.endsWith('/')) {
|
||||
directoryPath = `${directoryPath}/`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all files and subdirectories recursively
|
||||
const filesAndDirectories = await getDirectoryContentsRecursively(
|
||||
context.state.user.id,
|
||||
directoryPath,
|
||||
);
|
||||
|
||||
if (filesAndDirectories.length === 0) {
|
||||
return new Response('Directory not found or empty', { status: 404 });
|
||||
}
|
||||
|
||||
// Create zip archive
|
||||
const zipData = await createZipArchive(filesAndDirectories, directoryPath);
|
||||
|
||||
// Get directory name for filename
|
||||
const directoryName = directoryPath === '/'
|
||||
? 'root'
|
||||
: directoryPath.split('/').filter(Boolean).pop() || 'directory';
|
||||
|
||||
return new Response(zipData as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/zip',
|
||||
'content-disposition': `attachment; filename="${directoryName}.zip"`,
|
||||
'cache-control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating directory zip:', error);
|
||||
return new Response('Error creating zip archive', { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
content: Uint8Array;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
async function getDirectoryContentsRecursively(
|
||||
userId: string,
|
||||
directoryPath: string,
|
||||
parentPath = '',
|
||||
): Promise<FileEntry[]> {
|
||||
const entries: FileEntry[] = [];
|
||||
|
||||
try {
|
||||
// Get directories in current path
|
||||
const directories = await DirectoryModel.list(userId, directoryPath);
|
||||
for (const directory of directories) {
|
||||
const fullDirPath = join(directoryPath, directory.directory_name) + '/';
|
||||
const relativePath = join(parentPath, directory.directory_name);
|
||||
|
||||
// Add directory entry
|
||||
entries.push({
|
||||
path: relativePath + '/',
|
||||
content: new Uint8Array(0),
|
||||
isDirectory: true,
|
||||
});
|
||||
|
||||
// Recursively get contents of subdirectory
|
||||
const subEntries = await getDirectoryContentsRecursively(
|
||||
userId,
|
||||
fullDirPath,
|
||||
relativePath,
|
||||
);
|
||||
entries.push(...subEntries);
|
||||
}
|
||||
|
||||
// Get files in current path
|
||||
const files = await FileModel.list(userId, directoryPath);
|
||||
for (const file of files) {
|
||||
const fileResult = await FileModel.get(userId, directoryPath, file.file_name);
|
||||
if (fileResult.success && fileResult.contents) {
|
||||
const relativePath = join(parentPath, file.file_name);
|
||||
entries.push({
|
||||
path: relativePath,
|
||||
content: fileResult.contents as Uint8Array,
|
||||
isDirectory: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting directory contents:', error);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function createZipArchive(entries: FileEntry[], basePath: string): Promise<Uint8Array> {
|
||||
// Create a simple ZIP archive manually since we don't have a zip library
|
||||
// This is a basic implementation - in the future, we may want to consider using a robust library
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let centralDirectory: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
// Skip empty directories for now
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = encoder.encode(entry.path);
|
||||
const fileData = entry.content;
|
||||
|
||||
// Local file header
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
const headerView = new DataView(localHeader.buffer);
|
||||
|
||||
headerView.setUint32(0, 0x04034b50, true); // Local file header signature
|
||||
headerView.setUint16(4, 20, true); // Version needed to extract
|
||||
headerView.setUint16(6, 0, true); // General purpose bit flag
|
||||
headerView.setUint16(8, 0, true); // Compression method (stored)
|
||||
headerView.setUint16(10, 0, true); // File last modification time
|
||||
headerView.setUint16(12, 0, true); // File last modification date
|
||||
headerView.setUint32(14, crc32(fileData), true); // CRC-32
|
||||
headerView.setUint32(18, fileData.length, true); // Compressed size
|
||||
headerView.setUint32(22, fileData.length, true); // Uncompressed size
|
||||
headerView.setUint16(26, fileName.length, true); // File name length
|
||||
headerView.setUint16(28, 0, true); // Extra field length
|
||||
|
||||
localHeader.set(fileName, 30);
|
||||
chunks.push(localHeader);
|
||||
chunks.push(fileData);
|
||||
|
||||
// Central directory file header
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
const centralView = new DataView(centralHeader.buffer);
|
||||
|
||||
centralView.setUint32(0, 0x02014b50, true); // Central file header signature
|
||||
centralView.setUint16(4, 20, true); // Version made by
|
||||
centralView.setUint16(6, 20, true); // Version needed to extract
|
||||
centralView.setUint16(8, 0, true); // General purpose bit flag
|
||||
centralView.setUint16(10, 0, true); // Compression method
|
||||
centralView.setUint16(12, 0, true); // File last modification time
|
||||
centralView.setUint16(14, 0, true); // File last modification date
|
||||
centralView.setUint32(16, crc32(fileData), true); // CRC-32
|
||||
centralView.setUint32(20, fileData.length, true); // Compressed size
|
||||
centralView.setUint32(24, fileData.length, true); // Uncompressed size
|
||||
centralView.setUint16(28, fileName.length, true); // File name length
|
||||
centralView.setUint16(30, 0, true); // Extra field length
|
||||
centralView.setUint16(32, 0, true); // File comment length
|
||||
centralView.setUint16(34, 0, true); // Disk number start
|
||||
centralView.setUint16(36, 0, true); // Internal file attributes
|
||||
centralView.setUint32(38, 0, true); // External file attributes
|
||||
centralView.setUint32(42, offset, true); // Relative offset of local header
|
||||
|
||||
centralHeader.set(fileName, 46);
|
||||
centralDirectory.push(centralHeader);
|
||||
|
||||
offset += localHeader.length + fileData.length;
|
||||
}
|
||||
|
||||
// Central directory end record
|
||||
const centralDirData = new Uint8Array(centralDirectory.reduce((sum, arr) => sum + arr.length, 0));
|
||||
let centralOffset = 0;
|
||||
for (const dir of centralDirectory) {
|
||||
centralDirData.set(dir, centralOffset);
|
||||
centralOffset += dir.length;
|
||||
}
|
||||
|
||||
const endRecord = new Uint8Array(22);
|
||||
const endView = new DataView(endRecord.buffer);
|
||||
|
||||
endView.setUint32(0, 0x06054b50, true); // End of central directory signature
|
||||
endView.setUint16(4, 0, true); // Number of this disk
|
||||
endView.setUint16(6, 0, true); // Disk where central directory starts
|
||||
endView.setUint16(8, centralDirectory.length, true); // Central directory entries on this disk
|
||||
endView.setUint16(10, centralDirectory.length, true); // Total central directory entries
|
||||
endView.setUint32(12, centralDirData.length, true); // Central directory size
|
||||
endView.setUint32(16, offset, true); // Central directory offset
|
||||
endView.setUint16(20, 0, true); // ZIP file comment length
|
||||
|
||||
// Combine all parts
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0) +
|
||||
centralDirData.length + endRecord.length;
|
||||
const result = new Uint8Array(totalLength);
|
||||
|
||||
let resultOffset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, resultOffset);
|
||||
resultOffset += chunk.length;
|
||||
}
|
||||
|
||||
result.set(centralDirData, resultOffset);
|
||||
resultOffset += centralDirData.length;
|
||||
result.set(endRecord, resultOffset);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Simple CRC32 implementation
|
||||
function crc32(data: Uint8Array): number {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
table[i] = c;
|
||||
}
|
||||
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
|
@ -126,6 +126,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
|||
initialPath={data.currentPath}
|
||||
baseUrl={data.baseUrl}
|
||||
isFileSharingAllowed
|
||||
isDirectoryDownloadsAllowed={false}
|
||||
fileShareId={data.fileShareId}
|
||||
/>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface Data {
|
|||
currentPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isDirectoryDownloadsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
|
|
@ -40,6 +41,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||
const userFiles = await FileModel.list(context.state.user.id, currentPath);
|
||||
|
||||
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
|
||||
const isDirectoryDownloadsAllowed = await AppConfig.isDirectoryDownloadsAllowed();
|
||||
|
||||
return await context.render({
|
||||
userDirectories,
|
||||
|
|
@ -47,6 +49,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||
currentPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed: isPublicFileSharingAllowed,
|
||||
isDirectoryDownloadsAllowed,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -60,6 +63,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
|||
initialPath={data.currentPath}
|
||||
baseUrl={data.baseUrl}
|
||||
isFileSharingAllowed={data.isFileSharingAllowed}
|
||||
isDirectoryDownloadsAllowed={data.isDirectoryDownloadsAllowed}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
13
static/images/download.svg
Normal file
13
static/images/download.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
Loading…
Reference in a new issue