Compare commits
10 Commits
adde693585
...
3fdda5b34e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fdda5b34e | ||
|
|
6f7c534e59 | ||
|
|
8e1b9d1d70 | ||
|
|
86721d8877 | ||
|
|
b2dda31c51 | ||
|
|
6280228759 | ||
|
|
8d78e1f25c | ||
|
|
c4a5166e3b | ||
|
|
c81ef77370 | ||
|
|
1dcbf529a3 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
<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={() => 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)}
|
||||||
>
|
>
|
||||||
<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'
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
225
lib/utils/webdav_test.ts
Normal file
225
lib/utils/webdav_test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
78
routes/api/files/download-directory.tsx
Normal file
78
routes/api/files/download-directory.tsx
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
14
static/images/download.svg
Normal file
14
static/images/download.svg
Normal 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 |
Reference in New Issue
Block a user