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:
Tilman 2025-10-01 20:56:19 +02:00
parent c81ef77370
commit 5e913dd9d5
11 changed files with 317 additions and 4 deletions

View file

@ -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

View file

@ -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'

View file

@ -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}
/>

View file

@ -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,

View file

@ -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}
/>
);

View file

@ -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();

View file

@ -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 */

View 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;
}

View file

@ -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>

View file

@ -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>
);

View 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