Compare commits

...

10 Commits

Author SHA1 Message Date
Bruno Bernardino
3fdda5b34e Update Deno version
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Run Tests / test (push) Has been cancelled
2025-11-08 10:59:55 +00:00
Bruno Bernardino
6f7c534e59 Update deno.lock file 2025-11-08 10:52:02 +00:00
Bruno Bernardino
8e1b9d1d70 Merge pull request #113 from themadbit/generate-lockfile
generate lock file
2025-11-04 14:32:44 +00:00
themadbit
86721d8877 generate lock file 2025-11-04 12:03:50 +03:00
Bruno Bernardino
b2dda31c51 Revert xml lib, to avoid unexpected issues 2025-10-17 20:52:07 +01:00
Bruno Bernardino
6280228759 Fix XML parsing for WebDav
This was a regression caused by the `@libs/xml` upgrade in v2.6.0
2025-10-17 20:41:01 +01:00
Bruno Bernardino
8d78e1f25c Upgrade dependencies, fix directory download errors
Related to #106
2025-10-08 14:38:31 +01:00
Tilman
c4a5166e3b 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
2025-10-08 14:32:45 +01:00
Bruno Bernardino
c81ef77370 Fix linting 2025-10-01 14:20:51 +01:00
Bruno Bernardino
1dcbf529a3 Make initial News loading faster 2025-10-01 14:17:39 +01:00
24 changed files with 3291 additions and 19 deletions

2
.dvmrc
View File

@@ -1 +1 @@
2.5.2 2.5.6

View File

@@ -1,8 +1,8 @@
FROM denoland/deno:ubuntu-2.5.2 FROM denoland/deno:ubuntu-2.5.6
EXPOSE 8000 EXPOSE 8000
RUN apt-get update && apt-get install -y make RUN apt-get update && apt-get install -y make zip
WORKDIR /app WORKDIR /app

View File

@@ -18,6 +18,7 @@ const config: PartialDeep<Config> = {
// files: { // files: {
// rootPath: 'data-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) // allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
// allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files
// }, // },
// core: { // core: {
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // dashboard and files cannot be disabled // 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>; onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
onClickCreateShare?: (filePath: string) => void; onClickCreateShare?: (filePath: string) => void;
onClickOpenManageShare?: (fileShareId: string) => void; onClickOpenManageShare?: (fileShareId: string) => void;
onClickDownloadDirectory?: (parentPath: string, name: string) => void;
isShowingNotes?: boolean; isShowingNotes?: boolean;
isShowingPhotos?: boolean; isShowingPhotos?: boolean;
fileShareId?: string; fileShareId?: string;
@@ -39,6 +40,7 @@ export default function ListFiles(
onClickDeleteFile, onClickDeleteFile,
onClickCreateShare, onClickCreateShare,
onClickOpenManageShare, onClickOpenManageShare,
onClickDownloadDirectory,
isShowingNotes, isShowingNotes,
isShowingPhotos, isShowingPhotos,
fileShareId, fileShareId,
@@ -165,10 +167,26 @@ export default function ListFiles(
typeof onClickOpenMoveDirectory === 'undefined') typeof onClickOpenMoveDirectory === 'undefined')
? null ? 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 <span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' 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 <img
src='/images/rename.svg' src='/images/rename.svg'
@@ -181,8 +199,7 @@ export default function ListFiles(
</span> </span>
<span <span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/move.svg' src='/images/move.svg'

View File

@@ -48,6 +48,7 @@ interface MainFilesProps {
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
areDirectoryDownloadsAllowed: boolean;
fileShareId?: string; fileShareId?: string;
} }
@@ -58,6 +59,7 @@ export default function MainFiles(
initialPath, initialPath,
baseUrl, baseUrl,
isFileSharingAllowed, isFileSharingAllowed,
areDirectoryDownloadsAllowed,
fileShareId, fileShareId,
}: MainFilesProps, }: MainFilesProps,
) { ) {
@@ -411,6 +413,21 @@ export default function MainFiles(
moveDirectoryOrFileModal.value = null; moveDirectoryOrFileModal.value = null;
} }
function onClickDownloadDirectory(parentPath: string, name: string) {
// Create download URL with proper path encoding
const downloadUrl = `/api/files/download-directory?parentPath=${encodeURIComponent(parentPath)}&name=${
encodeURIComponent(name)
}`;
// 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) { async function onClickDeleteDirectory(parentPath: string, name: string, isBulkDeleting = false) {
if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) { if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) {
if (!isBulkDeleting && isDeleting.value) { if (!isBulkDeleting && isDeleting.value) {
@@ -839,6 +856,7 @@ export default function MainFiles(
onClickDeleteFile={onClickDeleteFile} onClickDeleteFile={onClickDeleteFile}
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined} onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined} onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
onClickDownloadDirectory={areDirectoryDownloadsAllowed ? onClickDownloadDirectory : undefined}
fileShareId={fileShareId} fileShareId={fileShareId}
/> />

View File

@@ -1,5 +1,5 @@
{ {
"lock": false, "lock": true,
"tasks": { "tasks": {
"check": "deno fmt --check && deno lint && deno check .", "check": "deno fmt --check && deno lint && deno check .",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
@@ -33,6 +33,7 @@
"fresh/": "https://deno.land/x/fresh@1.7.3/", "fresh/": "https://deno.land/x/fresh@1.7.3/",
"$fresh/": "https://deno.land/x/fresh@1.7.3/", "$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@libs/xml": "https://deno.land/x/xml@2.1.3/mod.ts",
"postgres": "jsr:@db/postgres@0.19.5", "postgres": "jsr:@db/postgres@0.19.5",
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@0.1.56", "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@0.1.56",
@@ -40,7 +41,6 @@
"@hexagon/croner": "jsr:@hexagon/croner@9.1.0", "@hexagon/croner": "jsr:@hexagon/croner@9.1.0",
"@mikaelporttila/rss": "jsr:@mikaelporttila/rss@1.1.3", "@mikaelporttila/rss": "jsr:@mikaelporttila/rss@1.1.3",
"@libs/qrcode": "jsr:@libs/qrcode@3.0.0", "@libs/qrcode": "jsr:@libs/qrcode@3.0.0",
"@libs/xml": "jsr:@libs/xml@7.0.2",
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.2.1", "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.2.1",
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.2.0", "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.2.0",
"@std/assert": "jsr:@std/assert@1.0.14", "@std/assert": "jsr:@std/assert@1.0.14",

2871
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ services:
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service. # NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
radicale: radicale:
image: tomsquest/docker-radicale:3.5.6.0 image: tomsquest/docker-radicale:3.5.7.0
ports: ports:
- 5232:5232 - 5232:5232
init: true init: true

View File

@@ -1,6 +1,6 @@
services: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v2.6.0 image: ghcr.io/bewcloud/bewcloud:v2.8.1
restart: always restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000
@@ -32,7 +32,7 @@ services:
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service. # NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
radicale: radicale:
image: tomsquest/docker-radicale:3.5.6.0 image: tomsquest/docker-radicale:3.5.7.0
# NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container # NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container
# ports: # ports:
# - 127.0.0.1:5232:5232 # - 127.0.0.1:5232:5232

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_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_share from './routes/api/files/delete-share.tsx';
import * as $api_files_delete from './routes/api/files/delete.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_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_share from './routes/api/files/get-share.tsx';
import * as $api_files_get from './routes/api/files/get.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-directory.tsx': $api_files_delete_directory,
'./routes/api/files/delete-share.tsx': $api_files_delete_share, './routes/api/files/delete-share.tsx': $api_files_delete_share,
'./routes/api/files/delete.tsx': $api_files_delete, './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-directories.tsx': $api_files_get_directories,
'./routes/api/files/get-share.tsx': $api_files_get_share, './routes/api/files/get-share.tsx': $api_files_get_share,
'./routes/api/files/get.tsx': $api_files_get, './routes/api/files/get.tsx': $api_files_get,

View File

@@ -7,6 +7,7 @@ interface FilesWrapperProps {
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
areDirectoryDownloadsAllowed: boolean;
fileShareId?: string; fileShareId?: string;
} }
@@ -18,6 +19,7 @@ export default function FilesWrapper(
initialPath, initialPath,
baseUrl, baseUrl,
isFileSharingAllowed, isFileSharingAllowed,
areDirectoryDownloadsAllowed,
fileShareId, fileShareId,
}: FilesWrapperProps, }: FilesWrapperProps,
) { ) {
@@ -28,6 +30,7 @@ export default function FilesWrapper(
initialPath={initialPath} initialPath={initialPath}
baseUrl={baseUrl} baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed} isFileSharingAllowed={isFileSharingAllowed}
areDirectoryDownloadsAllowed={areDirectoryDownloadsAllowed}
fileShareId={fileShareId} fileShareId={fileShareId}
/> />
); );

View File

@@ -15,6 +15,8 @@ interface Filter {
status: 'all' | 'unread'; status: 'all' | 'unread';
} }
let hasFetchedAllArticlesOnce = false;
export default function Articles({ initialArticles }: ArticlesProps) { export default function Articles({ initialArticles }: ArticlesProps) {
const isRefreshing = useSignal<boolean>(false); const isRefreshing = useSignal<boolean>(false);
const articles = useSignal<NewsFeedArticle[]>(initialArticles); const articles = useSignal<NewsFeedArticle[]>(initialArticles);
@@ -148,6 +150,11 @@ export default function Articles({ initialArticles }: ArticlesProps) {
function setNewFilter(newFilter: Partial<Filter>) { function setNewFilter(newFilter: Partial<Filter>) {
filter.value = { ...filter.value, ...newFilter }; filter.value = { ...filter.value, ...newFilter };
if (newFilter.status === 'all' && !hasFetchedAllArticlesOnce) {
refreshArticles();
hasFetchedAllArticlesOnce = true;
}
isFilterDropdownOpen.value = false; isFilterDropdownOpen.value = false;
} }

View File

@@ -22,6 +22,7 @@ export class AppConfig {
files: { files: {
rootPath: 'data-files', rootPath: 'data-files',
allowPublicSharing: false, allowPublicSharing: false,
allowDirectoryDownloads: false,
}, },
core: { core: {
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
@@ -179,6 +180,12 @@ export class AppConfig {
return this.config.files.allowPublicSharing; return this.config.files.allowPublicSharing;
} }
static async areDirectoryDownloadsAllowed(): Promise<boolean> {
await this.loadConfig();
return this.config.files.allowDirectoryDownloads;
}
static async getFilesRootPath(): Promise<string> { static async getFilesRootPath(): Promise<string> {
await this.loadConfig(); await this.loadConfig();

View File

@@ -598,7 +598,11 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string,
const resolvedFullPath = `${resolve(fullPath)}/`; const resolvedFullPath = `${resolve(fullPath)}/`;
if (!resolvedFullPath.startsWith(userRootPath)) { // Normalize path separators for consistent comparison on Windows
const normalizedUserRootPath = userRootPath.replaceAll('\\', '/');
const normalizedResolvedFullPath = resolvedFullPath.replaceAll('\\', '/');
if (!normalizedResolvedFullPath.startsWith(normalizedUserRootPath)) {
throw new Error('Invalid file path'); throw new Error('Invalid file path');
} }
} }

View File

@@ -214,6 +214,17 @@ export class ArticleModel {
return articles; return articles;
} }
static async listUnread(userId: string) {
const articles = await db.query<NewsFeedArticle>(
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 AND "is_read" = FALSE ORDER BY "article_date" DESC`,
[
userId,
],
);
return articles;
}
static async listByFeedId(feedId: string) { static async listByFeedId(feedId: string) {
const articles = await db.query<NewsFeedArticle>( const articles = await db.query<NewsFeedArticle>(
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`, sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`,

View File

@@ -184,6 +184,8 @@ export interface Config {
rootPath: string; rootPath: string;
/** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */ /** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */
allowPublicSharing: boolean; allowPublicSharing: boolean;
/** If true, directories can be downloaded as zip files */
allowDirectoryDownloads: boolean;
}; };
core: { core: {
/** dashboard and files cannot be disabled */ /** dashboard and files cannot be disabled */

View File

@@ -23,7 +23,7 @@ export function humanFileSize(bytes: number) {
export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) { export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) {
const nameA = entryA.name.toLocaleLowerCase(); const nameA = entryA.name.toLocaleLowerCase();
const nameB = entryB.name.toLocaleLowerCase(); const nameB = entryB.name.toLocaleLowerCase();
if (nameA > nameB) { if (nameA > nameB) {
return 1; return 1;
} }
@@ -38,7 +38,7 @@ export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry)
export function sortDirectoriesByName(directoryA: Directory, directoryB: Directory) { export function sortDirectoriesByName(directoryA: Directory, directoryB: Directory) {
const nameA = directoryA.directory_name.toLocaleLowerCase(); const nameA = directoryA.directory_name.toLocaleLowerCase();
const nameB = directoryB.directory_name.toLocaleLowerCase(); const nameB = directoryB.directory_name.toLocaleLowerCase();
if (nameA > nameB) { if (nameA > nameB) {
return 1; return 1;
} }
@@ -53,7 +53,7 @@ export function sortDirectoriesByName(directoryA: Directory, directoryB: Directo
export function sortFilesByName(fileA: DirectoryFile, fileB: DirectoryFile) { export function sortFilesByName(fileA: DirectoryFile, fileB: DirectoryFile) {
const nameA = fileA.file_name.toLocaleLowerCase(); const nameA = fileA.file_name.toLocaleLowerCase();
const nameB = fileB.file_name.toLocaleLowerCase(); const nameB = fileB.file_name.toLocaleLowerCase();
if (nameA > nameB) { if (nameA > nameB) {
return 1; return 1;
} }

225
lib/utils/webdav_test.ts Normal file
View File

@@ -0,0 +1,225 @@
import { assertEquals } from '@std/assert';
import { parse, stringify } from '@libs/xml';
import { addDavPrefixToKeys, getProperDestinationPath, getPropertyNames } from './webdav.ts';
Deno.test('that getProperDestinationPath works', () => {
const tests: { input: string; expected?: string }[] = [
{
input: `http://127.0.0.1/dav/12345-abcde-67890`,
expected: 'dav/12345-abcde-67890',
},
{
input: `http://127.0.0.1/dav/spaced-%20uid`,
expected: 'dav/spaced- uid',
},
{
input: `http://127.0.0.1/dav/something-deeper/spaced-%C3%A7uid`,
expected: 'dav/something-deeper/spaced-çuid',
},
];
for (const test of tests) {
const output = getProperDestinationPath(test.input);
if (test.expected) {
assertEquals(output, test.expected);
}
}
});
Deno.test('that addDavPrefixToKeys works', () => {
const tests: {
input: { object: Record<string, any> | Record<string, any>[]; prefix?: string };
expected: Record<string, any>;
}[] = [
{
input: {
object: {
displayname: 'test',
},
},
expected: {
'D:displayname': 'test',
},
},
{
input: {
object: [
{ displayname: 'test' },
{ color: 'black' },
],
},
expected: [
{ 'D:displayname': 'test' },
{ 'D:color': 'black' },
],
},
{
input: {
object: { '@version': '1.0', '@encoding': 'UTF-8', displayname: 'test', color: 'black' },
},
expected: {
'@version': '1.0',
'@encoding': 'UTF-8',
'D:displayname': 'test',
'D:color': 'black',
},
},
{
input: {
object: { displayname: 'test', color: 'black' },
prefix: 'S:',
},
expected: {
'S:displayname': 'test',
'S:color': 'black',
},
},
];
for (const test of tests) {
const output = addDavPrefixToKeys(test.input.object, test.input.prefix);
if (test.expected) {
assertEquals(output, test.expected);
}
}
});
Deno.test('that getPropertyNames works', () => {
const tests: {
input: Record<string, any>;
expected: string[];
}[] = [
{
input: {
'D:propfind': {
'D:prop': {
'D:displayname': 'test',
},
},
},
expected: ['displayname'],
},
{
input: {
'D:propfind': {
'D:prop': {
'D:displayname': 'test',
'D:color': 'black',
},
},
},
expected: ['displayname', 'color'],
},
{
input: {},
expected: ['allprop'],
},
];
for (const test of tests) {
const output = getPropertyNames(test.input);
if (test.expected) {
assertEquals(output, test.expected);
}
}
});
Deno.test('that @libs/xml.parse works', () => {
const tests: { input: string; expected: Record<string, any> }[] = [
{
input: `<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname>test</displayname>
</prop>
</propfind>`,
expected: {
xml: {
'@version': 1,
'@encoding': 'UTF-8',
},
propfind: {
'@xmlns': 'DAV:',
prop: {
displayname: 'test',
},
},
},
},
{
input: `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname>test</D:displayname>
</D:prop>
</D:propfind>`,
expected: {
xml: {
'@version': 1,
'@encoding': 'UTF-8',
},
'D:propfind': {
'@xmlns:D': 'DAV:',
'D:prop': {
'D:displayname': 'test',
},
},
},
},
];
for (const test of tests) {
const output = parse(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that @libs/xml.stringify works', () => {
const tests: { input: Record<string, any>; expected: string }[] = [
{
input: {
xml: {
'@version': '1.0',
'@encoding': 'UTF-8',
},
'D:propfind': {
'D:prop': {
'D:displayname': 'test',
},
},
},
expected: `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind>
<D:prop>
<D:displayname>test</D:displayname>
</D:prop>
</D:propfind>`,
},
{
input: {
xml: {
'@version': '1.0',
'@encoding': 'UTF-8',
},
'D:propfind': {
'@xmlns:D': 'DAV:',
'D:prop': {
'D:displayname': 'test',
},
},
},
expected: `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname>test</D:displayname>
</D:prop>
</D:propfind>`,
},
];
for (const test of tests) {
const output = stringify(test.input);
assertEquals(output, test.expected);
}
});

View File

@@ -0,0 +1,78 @@
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 as Error).message === 'Invalid file path') {
return new Response('Invalid directory path', { status: 400 });
}
return new Response('Error creating zip archive', { status: 500 });
}
},
};

View File

@@ -218,10 +218,17 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const depthString = request.headers.get('depth'); const depthString = request.headers.get('depth');
const depth = depthString ? parseInt(depthString, 10) : null; const depth = depthString ? parseInt(depthString, 10) : null;
const xml = await request.clone().text(); const xml = await request.clone().text();
let properties: string[] = [];
const parsedXml = parse(xml); try {
const parsedXml = parse(xml) as Record<string, any>;
const properties = getPropertyNames(parsedXml); properties = getPropertyNames(parsedXml);
} catch (error) {
console.error('Error parsing XML: ', error);
properties = ['allprop'];
}
await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);

View File

@@ -126,6 +126,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialPath={data.currentPath} initialPath={data.currentPath}
baseUrl={data.baseUrl} baseUrl={data.baseUrl}
isFileSharingAllowed isFileSharingAllowed
areDirectoryDownloadsAllowed={false}
fileShareId={data.fileShareId} fileShareId={data.fileShareId}
/> />
</main> </main>

View File

@@ -11,6 +11,7 @@ interface Data {
currentPath: string; currentPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
areDirectoryDownloadsAllowed: boolean;
} }
export const handler: Handlers<Data, FreshContextState> = { 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 userFiles = await FileModel.list(context.state.user.id, currentPath);
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
const areDirectoryDownloadsAllowed = await AppConfig.areDirectoryDownloadsAllowed();
return await context.render({ return await context.render({
userDirectories, userDirectories,
@@ -47,6 +49,7 @@ export const handler: Handlers<Data, FreshContextState> = {
currentPath, currentPath,
baseUrl, baseUrl,
isFileSharingAllowed: isPublicFileSharingAllowed, isFileSharingAllowed: isPublicFileSharingAllowed,
areDirectoryDownloadsAllowed,
}); });
}, },
}; };
@@ -60,6 +63,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialPath={data.currentPath} initialPath={data.currentPath}
baseUrl={data.baseUrl} baseUrl={data.baseUrl}
isFileSharingAllowed={data.isFileSharingAllowed} isFileSharingAllowed={data.isFileSharingAllowed}
areDirectoryDownloadsAllowed={data.areDirectoryDownloadsAllowed}
/> />
</main> </main>
); );

View File

@@ -19,7 +19,7 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } });
} }
const userArticles = await ArticleModel.list(context.state.user.id); const userArticles = await ArticleModel.listUnread(context.state.user.id);
return await context.render({ userArticles }); return await context.render({ userArticles });
}, },

View File

@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>

After

Width:  |  Height:  |  Size: 334 B