mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
Merge branch 'main' into feature/102-sort-by-header
This commit is contained in:
commit
c4fee9af0b
19 changed files with 178 additions and 15 deletions
2
.dvmrc
2
.dvmrc
|
|
@ -1 +1 @@
|
|||
2.5.2
|
||||
2.5.3
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
FROM denoland/deno:ubuntu-2.5.2
|
||||
FROM denoland/deno:ubuntu-2.5.3
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
RUN apt-get update && apt-get install -y make
|
||||
RUN apt-get update && apt-get install -y make zip
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
|
|||
|
|
@ -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: false, // If true, directories can be downloaded as zip files
|
||||
// },
|
||||
// core: {
|
||||
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // dashboard and files cannot be disabled
|
||||
|
|
|
|||
|
|
@ -20,6 +20,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;
|
||||
|
|
@ -44,6 +45,7 @@ export default function ListFiles(
|
|||
onClickDeleteFile,
|
||||
onClickCreateShare,
|
||||
onClickOpenManageShare,
|
||||
onClickDownloadDirectory,
|
||||
isShowingNotes,
|
||||
isShowingPhotos,
|
||||
fileShareId,
|
||||
|
|
@ -229,10 +231,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'
|
||||
|
|
@ -245,8 +263,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'
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ interface MainFilesProps {
|
|||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
areDirectoryDownloadsAllowed: boolean;
|
||||
fileShareId?: string;
|
||||
initialSortBy?: SortColumn;
|
||||
initialSortOrder?: SortOrder;
|
||||
|
|
@ -61,6 +62,7 @@ export default function MainFiles(
|
|||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
areDirectoryDownloadsAllowed,
|
||||
fileShareId,
|
||||
initialSortBy = 'name',
|
||||
initialSortOrder = 'asc',
|
||||
|
|
@ -487,6 +489,21 @@ export default function MainFiles(
|
|||
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) {
|
||||
if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) {
|
||||
if (!isBulkDeleting && isDeleting.value) {
|
||||
|
|
@ -920,6 +937,7 @@ export default function MainFiles(
|
|||
onClickDeleteFile={onClickDeleteFile}
|
||||
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
|
||||
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
|
||||
onClickDownloadDirectory={areDirectoryDownloadsAllowed ? onClickDownloadDirectory : undefined}
|
||||
fileShareId={fileShareId}
|
||||
sortBy={sortBy.value}
|
||||
sortOrder={sortOrder.value}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ services:
|
|||
|
||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||
radicale:
|
||||
image: tomsquest/docker-radicale:3.5.6.0
|
||||
image: tomsquest/docker-radicale:3.5.7.0
|
||||
ports:
|
||||
- 5232:5232
|
||||
init: true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
website:
|
||||
image: ghcr.io/bewcloud/bewcloud:v2.6.0
|
||||
image: ghcr.io/bewcloud/bewcloud:v2.7.0
|
||||
restart: always
|
||||
ports:
|
||||
- 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.
|
||||
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
|
||||
# ports:
|
||||
# - 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_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,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface FilesWrapperProps {
|
|||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
areDirectoryDownloadsAllowed: boolean;
|
||||
fileShareId?: string;
|
||||
initialSortBy?: SortColumn;
|
||||
initialSortOrder?: SortOrder;
|
||||
|
|
@ -21,6 +22,7 @@ export default function FilesWrapper(
|
|||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
areDirectoryDownloadsAllowed,
|
||||
fileShareId,
|
||||
initialSortBy = 'name',
|
||||
initialSortOrder = 'asc',
|
||||
|
|
@ -33,6 +35,7 @@ export default function FilesWrapper(
|
|||
initialPath={initialPath}
|
||||
baseUrl={baseUrl}
|
||||
isFileSharingAllowed={isFileSharingAllowed}
|
||||
areDirectoryDownloadsAllowed={areDirectoryDownloadsAllowed}
|
||||
fileShareId={fileShareId}
|
||||
initialSortBy={initialSortBy}
|
||||
initialSortOrder={initialSortOrder}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ interface Filter {
|
|||
status: 'all' | 'unread';
|
||||
}
|
||||
|
||||
let hasFetchedAllArticlesOnce = false;
|
||||
|
||||
export default function Articles({ initialArticles }: ArticlesProps) {
|
||||
const isRefreshing = useSignal<boolean>(false);
|
||||
const articles = useSignal<NewsFeedArticle[]>(initialArticles);
|
||||
|
|
@ -148,6 +150,11 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
function setNewFilter(newFilter: Partial<Filter>) {
|
||||
filter.value = { ...filter.value, ...newFilter };
|
||||
|
||||
if (newFilter.status === 'all' && !hasFetchedAllArticlesOnce) {
|
||||
refreshArticles();
|
||||
hasFetchedAllArticlesOnce = true;
|
||||
}
|
||||
|
||||
isFilterDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export class AppConfig {
|
|||
files: {
|
||||
rootPath: 'data-files',
|
||||
allowPublicSharing: false,
|
||||
allowDirectoryDownloads: false,
|
||||
},
|
||||
core: {
|
||||
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
||||
|
|
@ -179,6 +180,12 @@ export class AppConfig {
|
|||
return this.config.files.allowPublicSharing;
|
||||
}
|
||||
|
||||
static async areDirectoryDownloadsAllowed(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.files.allowDirectoryDownloads;
|
||||
}
|
||||
|
||||
static async getFilesRootPath(): Promise<string> {
|
||||
await this.loadConfig();
|
||||
|
||||
|
|
|
|||
|
|
@ -612,11 +612,9 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string,
|
|||
|
||||
const resolvedFullPath = `${resolve(fullPath)}/`;
|
||||
|
||||
console.log({ userRootPath, fullPath, resolvedFullPath });
|
||||
|
||||
// Normalize path separators for consistent comparison on Windows
|
||||
const normalizedUserRootPath = userRootPath.replace(/\\/g, '/');
|
||||
const normalizedResolvedFullPath = resolvedFullPath.replace(/\\/g, '/');
|
||||
const normalizedUserRootPath = userRootPath.replaceAll('\\', '/');
|
||||
const normalizedResolvedFullPath = resolvedFullPath.replaceAll('\\', '/');
|
||||
|
||||
if (!normalizedResolvedFullPath.startsWith(normalizedUserRootPath)) {
|
||||
throw new Error('Invalid file path');
|
||||
|
|
|
|||
|
|
@ -214,6 +214,17 @@ export class ArticleModel {
|
|||
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) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
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;
|
||||
/** 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 */
|
||||
|
|
|
|||
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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface Data {
|
|||
currentPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
areDirectoryDownloadsAllowed: boolean;
|
||||
sortBy: SortColumn;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
|
@ -56,6 +57,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||
const userFiles = await FileModel.list(context.state.user.id, currentPath, sortOptions);
|
||||
|
||||
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
|
||||
const areDirectoryDownloadsAllowed = await AppConfig.areDirectoryDownloadsAllowed();
|
||||
|
||||
return await context.render({
|
||||
userDirectories,
|
||||
|
|
@ -63,6 +65,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||
currentPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed: isPublicFileSharingAllowed,
|
||||
areDirectoryDownloadsAllowed,
|
||||
sortBy: finalSortBy,
|
||||
sortOrder: finalSortOrder,
|
||||
});
|
||||
|
|
@ -78,6 +81,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
|||
initialPath={data.currentPath}
|
||||
baseUrl={data.baseUrl}
|
||||
isFileSharingAllowed={data.isFileSharingAllowed}
|
||||
areDirectoryDownloadsAllowed={data.areDirectoryDownloadsAllowed}
|
||||
initialSortBy={data.sortBy}
|
||||
initialSortOrder={data.sortOrder}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||
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 });
|
||||
},
|
||||
|
|
|
|||
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 |
Loading…
Reference in a new issue