Support downloading directories as a zip archive (#106)
* 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. * Windows path bugfix * Include empty directories in zip archive * Address feedback - `isDirectoryDownloadsAllowed` -> `areDirectoryDownloadsAllowed` - send `parentPath` & `name` to API instead of resolving `fullPath` on client - call `ensureUserPathIsValidAndSecurelyAccessible` before zipping - set config `allowDirectoryDownloads` default to `false` - add `zip` to Dockerfile and replace in-house zip algorithm - replace `download.svg` with heroicon's `arrow-down-tray` - `replace` with glob -> `replaceAll` with string * Cleanup apt-get command * Remove unused zip archive and directory functions
This commit is contained in:
74
routes/api/files/download-directory.tsx
Normal file
74
routes/api/files/download-directory.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
import { join } from '@std/path';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { ensureUserPathIsValidAndSecurelyAccessible } 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;
|
||||
const parentPath = searchParams.get('parentPath') || '/';
|
||||
const name = searchParams.get('name');
|
||||
|
||||
if (!name) {
|
||||
return new Response('Directory name is required', { status: 400 });
|
||||
}
|
||||
|
||||
// Construct the full directory path
|
||||
const directoryPath = `${join(parentPath, name)}/`;
|
||||
|
||||
try {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(context.state.user.id, directoryPath);
|
||||
|
||||
// Get the actual filesystem path
|
||||
const filesRootPath = config.files?.rootPath || 'data-files';
|
||||
const userRootPath = join(filesRootPath, context.state.user.id);
|
||||
const fullDirectoryPath = join(userRootPath, directoryPath);
|
||||
|
||||
// Use the zip command to create the archive
|
||||
const zipProcess = new Deno.Command('zip', {
|
||||
args: ['-r', '-', '.'],
|
||||
cwd: fullDirectoryPath,
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const { code, stdout, stderr } = await zipProcess.output();
|
||||
|
||||
if (code !== 0) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
console.error('Zip command failed:', errorText);
|
||||
return new Response('Error creating zip archive', { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(stdout, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/zip',
|
||||
'content-disposition': `attachment; filename="${name}.zip"`,
|
||||
'cache-control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating directory zip:', error);
|
||||
if (error.message === 'Invalid file path') {
|
||||
return new Response('Invalid directory path', { status: 400 });
|
||||
}
|
||||
return new Response('Error creating zip archive', { status: 500 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -126,6 +126,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
||||
initialPath={data.currentPath}
|
||||
baseUrl={data.baseUrl}
|
||||
isFileSharingAllowed
|
||||
areDirectoryDownloadsAllowed={false}
|
||||
fileShareId={data.fileShareId}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Data {
|
||||
currentPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
areDirectoryDownloadsAllowed: 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 areDirectoryDownloadsAllowed = await AppConfig.areDirectoryDownloadsAllowed();
|
||||
|
||||
return await context.render({
|
||||
userDirectories,
|
||||
@@ -47,6 +49,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
currentPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed: isPublicFileSharingAllowed,
|
||||
areDirectoryDownloadsAllowed,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -60,6 +63,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
||||
initialPath={data.currentPath}
|
||||
baseUrl={data.baseUrl}
|
||||
isFileSharingAllowed={data.isFileSharingAllowed}
|
||||
areDirectoryDownloadsAllowed={data.areDirectoryDownloadsAllowed}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user