Public File Sharing (#72)

* Public File Sharing

This implements public file sharing (read-only) with and without passwords (#57).

It also fixes a problem with filenames including special characters like `#` not working properly (#71).

You can share a directory or a single file, by using the new share icon on the right of the directories/files, and click on it to manage an existing file share (setting a new password, or deleting the file share).

There is some other minor cleanup and other copy updates in the README.

Closes #57
Fixes #71

* Hide UI elements when sharing isn't allowed
This commit is contained in:
Bruno Bernardino
2025-06-20 12:04:16 +01:00
committed by GitHub
parent c7d6b8077b
commit 7fac7febcf
29 changed files with 1541 additions and 155 deletions

View File

@@ -14,12 +14,19 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/bewcloud/bewcloud) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/bewcloud/bewcloud)
[![Buy managed cloud (1 year)](https://img.shields.io/badge/Buy%20managed%20cloud%20(1%20year)-51a4fb?style=for-the-badge)](https://buy.stripe.com/eVa01HgQk0Ap0eseVz)
[![Buy managed cloud (1 month)](https://img.shields.io/badge/Buy%20managed%20cloud%20(1%20month)-51a4fb?style=for-the-badge)](https://buy.stripe.com/fZu8wOb5RfIydj56FA1gs0J)
Or on your own machine: Or on your own machine:
Download/copy [`docker-compose.yml`](/docker-compose.yml), [`.env.sample`](/.env.sample) as `.env`, and [`bewcloud.config.sample.ts`](/bewcloud.config.sample.ts) as `bewcloud.config.ts`. Download/copy [`docker-compose.yml`](/docker-compose.yml), [`.env.sample`](/.env.sample) as `.env`, and [`bewcloud.config.sample.ts`](/bewcloud.config.sample.ts) as `bewcloud.config.ts`.
> [!NOTE]
> `1993:1993` below comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it.
```sh ```sh
$ mkdir data-files # local directory for storing user-uploaded files $ mkdir data-files # local directory for storing user-uploaded files
$ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files $ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files
$ docker compose up -d # makes the app available at http://localhost:8000 $ docker compose up -d # makes the app available at http://localhost:8000
$ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates) $ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates)
@@ -30,9 +37,6 @@ Alternatively, check the [Development section below](#development).
> [!IMPORTANT] > [!IMPORTANT]
> Even with signups disabled (`config.auth.allowSignups=false`), the first signup will work and become an admin. > Even with signups disabled (`config.auth.allowSignups=false`), the first signup will work and become an admin.
> [!NOTE]
> `1993:1993` above comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it.
## Sponsors ## Sponsors
These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://donate.stripe.com/bIYeWBbw00Ape5iaFi) above $50 ($100 to show up on the website)! These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://donate.stripe.com/bIYeWBbw00Ape5iaFi) above $50 ($100 to show up on the website)!
@@ -91,9 +95,9 @@ Just push to the `main` branch.
My focus was to get me to replace Nextcloud for me and my family ASAP, and it turns out it's not easy to do it all in a single, installable _thing_, so I focused on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient). My focus was to get me to replace Nextcloud for me and my family ASAP, and it turns out it's not easy to do it all in a single, installable _thing_, so I focused on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient).
## How does file sharing work? ## How does private file sharing work?
[Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link), as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks). Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks).
You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-to-share> /<absolute-path-to-data-files>/<user-id-to-share-with>/` to create a shared directory between two users, and the same directory can have different names, now. You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-to-share> /<absolute-path-to-data-files>/<user-id-to-share-with>/` to create a shared directory between two users, and the same directory can have different names, now.

View File

@@ -13,6 +13,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)
// }, // },
// core: { // core: {
// enabledApps: ['news', 'notes', 'photos', 'expenses'], // dashboard and files cannot be disabled // enabledApps: ['news', 'notes', 'photos', 'expenses'], // dashboard and files cannot be disabled

View File

@@ -28,9 +28,9 @@ export default function MultiFactorAuthVerifyForm(
{error {error
? ( ? (
<section class='bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded relative mb-4'> <section class='notification-error'>
<strong class='font-bold'>{error.title}:</strong> <h3>{error.title}</h3>
<span class='block sm:inline'>{error.message}</span> <p>{error.message}</p>
</section> </section>
) )
: null} : null}

View File

@@ -0,0 +1,67 @@
import { useSignal } from '@preact/signals';
interface CreateShareModalProps {
isOpen: boolean;
filePath: string;
password?: string;
onClickSave: (filePath: string, password?: string) => Promise<void>;
onClose: () => void;
}
export default function CreateShareModal(
{ isOpen, filePath, password, onClickSave, onClose }: CreateShareModalProps,
) {
const newPassword = useSignal<string>(password || '');
return (
<>
<section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
isOpen ? 'block' : 'hidden'
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
>
<h1 class='text-2xl font-semibold my-5'>Create New Public Share Link</h1>
<section class='py-5 my-2 border-y border-slate-500'>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='password'>Password</label>
<input
class='input-field'
type='password'
name='password'
id='password'
value={newPassword.value}
onInput={(event) => {
newPassword.value = event.currentTarget.value;
}}
autocomplete='off'
/>
</fieldset>
</section>
<footer class='flex justify-between'>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => {
onClickSave(filePath, newPassword.peek());
newPassword.value = '';
}}
type='button'
>
Create
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()}
type='button'
>
Close
</button>
</footer>
</section>
</>
);
}

View File

@@ -2,22 +2,24 @@ interface FilesBreadcrumbProps {
path: string; path: string;
isShowingNotes?: boolean; isShowingNotes?: boolean;
isShowingPhotos?: boolean; isShowingPhotos?: boolean;
fileShareId?: string;
} }
export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos }: FilesBreadcrumbProps) { export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos, fileShareId }: FilesBreadcrumbProps) {
let routePath = 'files'; let routePath = fileShareId ? `file-share/${fileShareId}` : 'files';
let rootPath = '/'; let rootPath = '/';
let itemPluralLabel = 'files';
if (isShowingNotes) { if (isShowingNotes) {
routePath = 'notes'; routePath = 'notes';
itemPluralLabel = 'notes';
rootPath = '/Notes/'; rootPath = '/Notes/';
} else if (isShowingPhotos) { } else if (isShowingPhotos) {
routePath = 'photos'; routePath = 'photos';
itemPluralLabel = 'photos';
rootPath = '/Photos/'; rootPath = '/Photos/';
} }
const itemPluralLabel = routePath;
if (path === rootPath) { if (path === rootPath) {
return ( return (
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'> <h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
@@ -30,7 +32,7 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos
return ( return (
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'> <h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
{!isShowingNotes && !isShowingPhotos ? <a href={`/files?path=/`}>All files</a> : null} {!isShowingNotes && !isShowingPhotos ? <a href={`/${routePath}?path=/`}>All files</a> : null}
{isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : null} {isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : null}
{isShowingPhotos ? <a href={`/photos?path=/Photos/`}>All photos</a> : null} {isShowingPhotos ? <a href={`/photos?path=/Photos/`}>All photos</a> : null}
{pathParts.map((part, index) => { {pathParts.map((part, index) => {
@@ -57,7 +59,9 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos
return ( return (
<> <>
<span class='ml-2 text-xs'>/</span> <span class='ml-2 text-xs'>/</span>
<a href={`/${routePath}?path=/${fullPathForPart.join('/')}/`} class='ml-2'>{decodeURIComponent(part)}</a> <a href={`/${routePath}?path=/${encodeURIComponent(fullPathForPart.join('/'))}/`} class='ml-2'>
{decodeURIComponent(part)}
</a>
</> </>
); );
})} })}

View File

@@ -1,3 +1,5 @@
import { join } from 'std/path/join.ts';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts'; import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
@@ -14,8 +16,11 @@ interface ListFilesProps {
onClickOpenMoveFile?: (parentPath: string, name: string) => void; onClickOpenMoveFile?: (parentPath: string, name: string) => void;
onClickDeleteDirectory?: (parentPath: string, name: string) => Promise<void>; onClickDeleteDirectory?: (parentPath: string, name: string) => Promise<void>;
onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>; onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
onClickCreateShare?: (filePath: string) => void;
onClickOpenManageShare?: (fileShareId: string) => void;
isShowingNotes?: boolean; isShowingNotes?: boolean;
isShowingPhotos?: boolean; isShowingPhotos?: boolean;
fileShareId?: string;
} }
export default function ListFiles( export default function ListFiles(
@@ -32,8 +37,11 @@ export default function ListFiles(
onClickOpenMoveFile, onClickOpenMoveFile,
onClickDeleteDirectory, onClickDeleteDirectory,
onClickDeleteFile, onClickDeleteFile,
onClickCreateShare,
onClickOpenManageShare,
isShowingNotes, isShowingNotes,
isShowingPhotos, isShowingPhotos,
fileShareId,
}: ListFilesProps, }: ListFilesProps,
) { ) {
const dateFormat = new Intl.DateTimeFormat('en-GB', { const dateFormat = new Intl.DateTimeFormat('en-GB', {
@@ -45,7 +53,7 @@ export default function ListFiles(
minute: '2-digit', minute: '2-digit',
}); });
let routePath = 'files'; let routePath = fileShareId ? `file-share/${fileShareId}` : 'files';
let itemSingleLabel = 'file'; let itemSingleLabel = 'file';
let itemPluralLabel = 'files'; let itemPluralLabel = 'files';
@@ -81,7 +89,8 @@ export default function ListFiles(
<thead> <thead>
<tr class='border-b border-slate-600'> <tr class='border-b border-slate-600'>
{(directories.length === 0 && files.length === 0) || {(directories.length === 0 && files.length === 0) ||
(typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined') (typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined') ||
fileShareId
? null ? null
: ( : (
<th scope='col' class='pl-6 pr-2 font-medium text-white w-3'> <th scope='col' class='pl-6 pr-2 font-medium text-white w-3'>
@@ -98,7 +107,9 @@ export default function ListFiles(
{isShowingNotes || isShowingPhotos {isShowingNotes || isShowingPhotos
? null ? null
: <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>} : <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>}
{isShowingPhotos ? null : <th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>} {isShowingPhotos || fileShareId
? null
: <th scope='col' class='px-6 py-4 font-medium text-white w-24'></th>}
</tr> </tr>
</thead> </thead>
<tbody class='divide-y divide-slate-600 border-t border-slate-600'> <tbody class='divide-y divide-slate-600 border-t border-slate-600'>
@@ -107,7 +118,7 @@ export default function ListFiles(
return ( return (
<tr class='bg-slate-700 hover:bg-slate-600 group'> <tr class='bg-slate-700 hover:bg-slate-600 group'>
{typeof onClickChooseDirectory === 'undefined' ? null : ( {typeof onClickChooseDirectory === 'undefined' || fileShareId ? null : (
<td class='gap-3 pl-6 pr-2 py-4'> <td class='gap-3 pl-6 pr-2 py-4'>
{fullPath === TRASH_PATH ? null : ( {fullPath === TRASH_PATH ? null : (
<input <input
@@ -124,7 +135,7 @@ export default function ListFiles(
)} )}
<td class='flex gap-3 px-6 py-4'> <td class='flex gap-3 px-6 py-4'>
<a <a
href={`/${routePath}?path=${fullPath}`} href={`/${routePath}?path=${encodeURIComponent(fullPath)}`}
class='flex items-center font-normal text-white' class='flex items-center font-normal text-white'
> >
<img <img
@@ -146,13 +157,13 @@ export default function ListFiles(
- -
</td> </td>
)} )}
{isShowingPhotos ? null : ( {isShowingPhotos || fileShareId ? null : (
<td class='px-6 py-4'> <td class='px-6 py-4'>
{(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' ||
typeof onClickOpenMoveDirectory === 'undefined') typeof onClickOpenMoveDirectory === 'undefined')
? null ? null
: ( : (
<section class='flex items-center justify-end w-20'> <section class='flex items-center justify-end w-24'>
<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)}
@@ -168,7 +179,8 @@ 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={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)} onClick={() =>
onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/move.svg' src='/images/move.svg'
@@ -181,7 +193,7 @@ export default function ListFiles(
</span> </span>
{typeof onClickDeleteDirectory === 'undefined' ? null : ( {typeof onClickDeleteDirectory === 'undefined' ? null : (
<span <span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100' class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)} onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
@@ -194,6 +206,36 @@ export default function ListFiles(
/> />
</span> </span>
)} )}
{typeof onClickCreateShare === 'undefined' || directory.file_share_id ? null : (
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))}
>
<img
src='/images/share.svg'
class='white drop-shadow-md'
width={18}
height={18}
alt='Create public share link'
title='Create public share link'
/>
</span>
)}
{typeof onClickOpenManageShare === 'undefined' || !directory.file_share_id ? null : (
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickOpenManageShare(directory.file_share_id!)}
>
<img
src='/images/share.svg'
class='white drop-shadow-md'
width={18}
height={18}
alt='Manage public share link'
title='Manage public share link'
/>
</span>
)}
</section> </section>
)} )}
</td> </td>
@@ -203,7 +245,7 @@ export default function ListFiles(
})} })}
{files.map((file) => ( {files.map((file) => (
<tr class='bg-slate-700 hover:bg-slate-600 group'> <tr class='bg-slate-700 hover:bg-slate-600 group'>
{typeof onClickChooseFile === 'undefined' ? null : ( {typeof onClickChooseFile === 'undefined' || fileShareId ? null : (
<td class='gap-3 pl-6 pr-2 py-4'> <td class='gap-3 pl-6 pr-2 py-4'>
<input <input
class='w-3 h-3 cursor-pointer text-[#51A4FB] bg-slate-100 border-slate-300 rounded dark:bg-slate-700 dark:border-slate-600' class='w-3 h-3 cursor-pointer text-[#51A4FB] bg-slate-100 border-slate-300 rounded dark:bg-slate-700 dark:border-slate-600'
@@ -219,7 +261,9 @@ export default function ListFiles(
)} )}
<td class='flex gap-3 px-6 py-4'> <td class='flex gap-3 px-6 py-4'>
<a <a
href={`/${routePath}/open/${file.file_name}?path=${file.parent_path}`} href={`/${routePath}/open/${encodeURIComponent(file.file_name)}?path=${
encodeURIComponent(file.parent_path)
}`}
class='flex items-center font-normal text-white' class='flex items-center font-normal text-white'
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
@@ -243,9 +287,9 @@ export default function ListFiles(
{humanFileSize(file.size_in_bytes)} {humanFileSize(file.size_in_bytes)}
</td> </td>
)} )}
{isShowingPhotos ? null : ( {isShowingPhotos || fileShareId ? null : (
<td class='px-6 py-4'> <td class='px-6 py-4'>
<section class='flex items-center justify-end w-20'> <section class='flex items-center justify-end w-24'>
{typeof onClickOpenRenameFile === 'undefined' ? null : ( {typeof onClickOpenRenameFile === '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'
@@ -278,7 +322,7 @@ export default function ListFiles(
)} )}
{typeof onClickDeleteFile === 'undefined' ? null : ( {typeof onClickDeleteFile === 'undefined' ? null : (
<span <span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100' class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)} onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
> >
<img <img
@@ -291,6 +335,36 @@ export default function ListFiles(
/> />
</span> </span>
)} )}
{typeof onClickCreateShare === 'undefined' || file.file_share_id ? null : (
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))}
>
<img
src='/images/share.svg'
class='white drop-shadow-md'
width={18}
height={18}
alt='Create public share link'
title='Create public share link'
/>
</span>
)}
{typeof onClickOpenManageShare === 'undefined' || !file.file_share_id ? null : (
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickOpenManageShare(file.file_share_id!)}
>
<img
src='/images/share.svg'
class='white drop-shadow-md'
width={18}
height={18}
alt='Manage public share link'
title='Manage public share link'
/>
</span>
)}
</section> </section>
</td> </td>
)} )}

View File

@@ -58,7 +58,7 @@ export default function ListFiles(
<tr class='bg-slate-700 hover:bg-slate-600 group'> <tr class='bg-slate-700 hover:bg-slate-600 group'>
<td class='flex gap-3 px-6 py-4'> <td class='flex gap-3 px-6 py-4'>
<a <a
href={`/${routePath}?path=${fullPath}`} href={`/${routePath}?path=${encodeURIComponent(fullPath)}`}
class='flex items-center font-normal text-white' class='flex items-center font-normal text-white'
> >
<img <img
@@ -135,7 +135,9 @@ export default function ListFiles(
<tr class='bg-slate-700 hover:bg-slate-600 group'> <tr class='bg-slate-700 hover:bg-slate-600 group'>
<td class='flex gap-3 px-6 py-4'> <td class='flex gap-3 px-6 py-4'>
<a <a
href={`/${routePath}/open/${file.file_name}?path=${file.parent_path}`} href={`/${routePath}/open/${encodeURIComponent(file.file_name)}?path=${
encodeURIComponent(file.parent_path)
}`}
class='flex items-center font-normal text-white' class='flex items-center font-normal text-white'
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'

View File

@@ -21,21 +21,39 @@ import {
RequestBody as DeleteDirectoryRequestBody, RequestBody as DeleteDirectoryRequestBody,
ResponseBody as DeleteDirectoryResponseBody, ResponseBody as DeleteDirectoryResponseBody,
} from '/routes/api/files/delete-directory.tsx'; } from '/routes/api/files/delete-directory.tsx';
import {
RequestBody as CreateShareRequestBody,
ResponseBody as CreateShareResponseBody,
} from '/routes/api/files/create-share.tsx';
import {
RequestBody as UpdateShareRequestBody,
ResponseBody as UpdateShareResponseBody,
} from '/routes/api/files/update-share.tsx';
import {
RequestBody as DeleteShareRequestBody,
ResponseBody as DeleteShareResponseBody,
} from '/routes/api/files/delete-share.tsx';
import SearchFiles from './SearchFiles.tsx'; import SearchFiles from './SearchFiles.tsx';
import ListFiles from './ListFiles.tsx'; import ListFiles from './ListFiles.tsx';
import FilesBreadcrumb from './FilesBreadcrumb.tsx'; import FilesBreadcrumb from './FilesBreadcrumb.tsx';
import CreateDirectoryModal from './CreateDirectoryModal.tsx'; import CreateDirectoryModal from './CreateDirectoryModal.tsx';
import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx'; import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx';
import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx'; import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx';
import CreateShareModal from './CreateShareModal.tsx';
import ManageShareModal from './ManageShareModal.tsx';
interface MainFilesProps { interface MainFilesProps {
initialDirectories: Directory[]; initialDirectories: Directory[];
initialFiles: DirectoryFile[]; initialFiles: DirectoryFile[];
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean;
fileShareId?: string;
} }
export default function MainFiles({ initialDirectories, initialFiles, initialPath, baseUrl }: MainFilesProps) { export default function MainFiles(
{ initialDirectories, initialFiles, initialPath, baseUrl, isFileSharingAllowed, fileShareId }: MainFilesProps,
) {
const isAdding = useSignal<boolean>(false); const isAdding = useSignal<boolean>(false);
const isUploading = useSignal<boolean>(false); const isUploading = useSignal<boolean>(false);
const isDeleting = useSignal<boolean>(false); const isDeleting = useSignal<boolean>(false);
@@ -56,6 +74,8 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
const moveDirectoryOrFileModal = useSignal< const moveDirectoryOrFileModal = useSignal<
{ isOpen: boolean; isDirectory: boolean; path: string; name: string } | null { isOpen: boolean; isDirectory: boolean; path: string; name: string } | null
>(null); >(null);
const createShareModal = useSignal<{ isOpen: boolean; filePath: string; password?: string } | null>(null);
const manageShareModal = useSignal<{ isOpen: boolean; fileShareId: string } | null>(null);
function onClickUploadFile(uploadDirectory = false) { function onClickUploadFile(uploadDirectory = false) {
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
@@ -483,12 +503,150 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
} }
} }
function onClickCreateShare(filePath: string) {
if (createShareModal.value?.isOpen) {
createShareModal.value = null;
return;
}
createShareModal.value = {
isOpen: true,
filePath,
};
}
async function onClickSaveFileShare(filePath: string, password?: string) {
if (isAdding.value) {
return;
}
if (!filePath) {
return;
}
isAdding.value = true;
try {
const requestBody: CreateShareRequestBody = {
pathInView: path.value,
filePath,
password,
};
const response = await fetch(`/api/files/create-share`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as CreateShareResponseBody;
if (!result.success) {
throw new Error('Failed to create share!');
}
directories.value = [...result.newDirectories];
files.value = [...result.newFiles];
createShareModal.value = null;
onClickOpenManageShare(result.createdFileShareId);
} catch (error) {
console.error(error);
}
isAdding.value = false;
}
function onClickCloseFileShare() {
createShareModal.value = null;
}
function onClickOpenManageShare(fileShareId: string) {
manageShareModal.value = {
isOpen: true,
fileShareId,
};
}
async function onClickUpdateFileShare(fileShareId: string, password?: string) {
if (isUpdating.value) {
return;
}
if (!fileShareId) {
return;
}
isUpdating.value = true;
try {
const requestBody: UpdateShareRequestBody = {
pathInView: path.value,
fileShareId,
password,
};
const response = await fetch(`/api/files/update-share`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as UpdateShareResponseBody;
if (!result.success) {
throw new Error('Failed to update share!');
}
directories.value = [...result.newDirectories];
files.value = [...result.newFiles];
manageShareModal.value = null;
} catch (error) {
console.error(error);
}
isUpdating.value = false;
}
function onClickCloseManageShare() {
manageShareModal.value = null;
}
async function onClickDeleteFileShare(fileShareId: string) {
if (!fileShareId || isDeleting.value || !confirm('Are you sure you want to delete this public share link?')) {
return;
}
isDeleting.value = true;
try {
const requestBody: DeleteShareRequestBody = {
pathInView: path.value,
fileShareId,
};
const response = await fetch(`/api/files/delete-share`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as DeleteShareResponseBody;
if (!result.success) {
throw new Error('Failed to delete file share!');
}
directories.value = [...result.newDirectories];
files.value = [...result.newFiles];
manageShareModal.value = null;
} catch (error) {
console.error(error);
}
isDeleting.value = false;
}
return ( return (
<> <>
<section class='flex flex-row items-center justify-between mb-4'> <section class='flex flex-row items-center justify-between mb-4'>
<section class='relative inline-block text-left mr-2'> <section class='relative inline-block text-left mr-2'>
<section class='flex flex-row items-center justify-start'> <section class='flex flex-row items-center justify-start'>
<SearchFiles /> {!fileShareId ? <SearchFiles /> : null}
{isAnyItemChosen {isAnyItemChosen
? ( ? (
@@ -539,63 +697,67 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
</section> </section>
<section class='flex items-center justify-end'> <section class='flex items-center justify-end'>
<FilesBreadcrumb path={path.value} /> <FilesBreadcrumb path={path.value} fileShareId={fileShareId} />
<section class='relative inline-block text-left ml-2'> {!fileShareId
<div> ? (
<button <section class='relative inline-block text-left ml-2'>
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2' <div>
type='button' <button
title='Add new file or directory' class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
id='new-button' type='button'
aria-expanded='true' title='Add new file or directory'
aria-haspopup='true' id='new-button'
onClick={() => toggleNewOptionsDropdown()} aria-expanded='true'
> aria-haspopup='true'
<img onClick={() => toggleNewOptionsDropdown()}
src='/images/add.svg' >
alt='Add new file or directory' <img
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`} src='/images/add.svg'
width={20} alt='Add new file or directory'
height={20} class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
/> width={20}
</button> height={20}
</div> />
</button>
</div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
!areNewOptionsOpen.value ? 'hidden' : '' !areNewOptionsOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
aria-orientation='vertical' aria-orientation='vertical'
aria-labelledby='new-button' aria-labelledby='new-button'
tabindex={-1} tabindex={-1}
>
<div class='py-1'>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile()}
type='button'
> >
Upload Files <div class='py-1'>
</button> <button
<button class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} onClick={() => onClickUploadFile()}
onClick={() => onClickUploadFile(true)} type='button'
type='button' >
> Upload Files
Upload Directory </button>
</button> <button
<button class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} onClick={() => onClickUploadFile(true)}
onClick={() => onClickCreateDirectory()} type='button'
type='button' >
> Upload Directory
New Directory </button>
</button> <button
</div> class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
</div> onClick={() => onClickCreateDirectory()}
</section> type='button'
>
New Directory
</button>
</div>
</div>
</section>
)
: null}
</section> </section>
</section> </section>
@@ -613,6 +775,9 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
onClickOpenMoveFile={onClickOpenMoveFile} onClickOpenMoveFile={onClickOpenMoveFile}
onClickDeleteDirectory={onClickDeleteDirectory} onClickDeleteDirectory={onClickDeleteDirectory}
onClickDeleteFile={onClickDeleteFile} onClickDeleteFile={onClickDeleteFile}
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
fileShareId={fileShareId}
/> />
<span <span
@@ -650,33 +815,76 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
</span> </span>
</section> </section>
<section class='flex flex-row items-center justify-start my-12'> {!fileShareId
<span class='font-semibold'>WebDav URL:</span>{' '} ? (
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav</code> <section class='flex flex-row items-center justify-start my-12'>
</section> <span class='font-semibold'>WebDav URL:</span>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav</code>
</section>
)
: null}
<CreateDirectoryModal {!fileShareId
isOpen={isNewDirectoryModalOpen.value} ? (
onClickSave={onClickSaveDirectory} <CreateDirectoryModal
onClose={onCloseCreateDirectory} isOpen={isNewDirectoryModalOpen.value}
/> onClickSave={onClickSaveDirectory}
onClose={onCloseCreateDirectory}
/>
)
: null}
<RenameDirectoryOrFileModal {!fileShareId
isOpen={renameDirectoryOrFileModal.value?.isOpen || false} ? (
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false} <RenameDirectoryOrFileModal
initialName={renameDirectoryOrFileModal.value?.name || ''} isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
onClickSave={renameDirectoryOrFileModal.value?.isDirectory ? onClickSaveRenameDirectory : onClickSaveRenameFile} isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
onClose={onClickCloseRename} initialName={renameDirectoryOrFileModal.value?.name || ''}
/> onClickSave={renameDirectoryOrFileModal.value?.isDirectory
? onClickSaveRenameDirectory
: onClickSaveRenameFile}
onClose={onClickCloseRename}
/>
)
: null}
<MoveDirectoryOrFileModal {!fileShareId
isOpen={moveDirectoryOrFileModal.value?.isOpen || false} ? (
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false} <MoveDirectoryOrFileModal
initialPath={moveDirectoryOrFileModal.value?.path || ''} isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
name={moveDirectoryOrFileModal.value?.name || ''} isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile} initialPath={moveDirectoryOrFileModal.value?.path || ''}
onClose={onClickCloseMove} name={moveDirectoryOrFileModal.value?.name || ''}
/> onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile}
onClose={onClickCloseMove}
/>
)
: null}
{!fileShareId && isFileSharingAllowed
? (
<CreateShareModal
isOpen={createShareModal.value?.isOpen || false}
filePath={createShareModal.value?.filePath || ''}
password={createShareModal.value?.password || ''}
onClickSave={onClickSaveFileShare}
onClose={onClickCloseFileShare}
/>
)
: null}
{!fileShareId && isFileSharingAllowed
? (
<ManageShareModal
baseUrl={baseUrl}
isOpen={manageShareModal.value?.isOpen || false}
fileShareId={manageShareModal.value?.fileShareId || ''}
onClickSave={onClickUpdateFileShare}
onClickDelete={onClickDeleteFileShare}
onClose={onClickCloseManageShare}
/>
)
: null}
</> </>
); );
} }

View File

@@ -0,0 +1,121 @@
import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks';
import { RequestBody, ResponseBody } from '/routes/api/files/get-share.tsx';
import { FileShare } from '/lib/types.ts';
interface ManageShareModalProps {
baseUrl: string;
isOpen: boolean;
fileShareId: string;
onClickSave: (fileShareId: string, password?: string) => Promise<void>;
onClickDelete: (fileShareId: string) => Promise<void>;
onClose: () => void;
}
export default function ManageShareModal(
{ baseUrl, isOpen, fileShareId, onClickSave, onClickDelete, onClose }: ManageShareModalProps,
) {
const newPassword = useSignal<string>('');
const isLoading = useSignal<boolean>(false);
const fileShare = useSignal<FileShare | null>(null);
useEffect(() => {
fetchFileShare();
}, [fileShareId]);
async function fetchFileShare() {
if (!fileShareId || isLoading.value) {
return;
}
isLoading.value = true;
try {
const requestBody: RequestBody = {
fileShareId,
};
const response = await fetch(`/api/files/get-share`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as ResponseBody;
if (!result.success) {
throw new Error('Failed to get file share!');
}
fileShare.value = result.fileShare;
isLoading.value = false;
} catch (error) {
console.error(error);
}
}
return (
<>
<section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
isOpen ? 'block' : 'hidden'
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
>
<h1 class='text-2xl font-semibold my-5'>Manage Public Share Link</h1>
<section class='py-5 my-2 border-y border-slate-500'>
<section class='block mb-2'>
<span class='font-semibold my-2 block'>Public Share URL:</span>{' '}
<code class='bg-slate-700 my-2 px-2 py-1 rounded-md'>{baseUrl}/file-share/{fileShareId}</code>
</section>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='password'>
{fileShare.value?.extra.hashed_password ? 'New Password' : 'Set Password'}
</label>
<input
class='input-field'
type='password'
name='password'
id='password'
value={newPassword.value}
onInput={(event) => {
newPassword.value = event.currentTarget.value;
}}
autocomplete='off'
/>
</fieldset>
</section>
<footer class='flex justify-between'>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => {
onClickSave(fileShareId, newPassword.peek());
newPassword.value = '';
}}
type='button'
>
Update
</button>
<button
class='px-5 py-2 bg-red-600 hover:bg-red-500 text-white cursor-pointer rounded-md'
onClick={() => onClickDelete(fileShareId)}
type='button'
>
Delete
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()}
type='button'
>
Close
</button>
</footer>
</section>
</>
);
}

View File

@@ -116,7 +116,9 @@ export default function SearchFiles({}: SearchFilesProps) {
{matchingDirectories.value.map((directory) => ( {matchingDirectories.value.map((directory) => (
<li class='mb-1'> <li class='mb-1'>
<a <a
href={`/files?path=${directory.parent_path}${directory.directory_name}`} href={`/files?path=${encodeURIComponent(directory.parent_path)}${
encodeURIComponent(directory.directory_name)
}`}
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer font-normal`} class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer font-normal`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
@@ -136,7 +138,9 @@ export default function SearchFiles({}: SearchFilesProps) {
{matchingFiles.value.map((file) => ( {matchingFiles.value.map((file) => (
<li class='mb-1'> <li class='mb-1'>
<a <a
href={`/files/open/${file.file_name}?path=${file.parent_path}`} href={`/files/open/${encodeURIComponent(file.file_name)}?path=${
encodeURIComponent(file.parent_path)
}`}
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer font-normal`} class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer font-normal`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'

View File

@@ -0,0 +1,58 @@
interface ShareVerifyFormProps {
error?: { title: string; message: string };
}
export default function ShareVerifyForm(
{ error }: ShareVerifyFormProps,
) {
return (
<section class='max-w-md w-full mb-12'>
<section class='mb-6'>
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
File Share Authentication
</h2>
<p class='mt-2 text-center text-sm text-gray-300'>
You are required to authenticate with a password
</p>
</section>
{error
? (
<section class='notification-error'>
<h3>{error.title}</h3>
<p>{error.message}</p>
</section>
)
: null}
<form
class='mb-6'
method='POST'
>
<fieldset class='block mb-4'>
<label class='text-slate-300 block pb-1' for='token'>
Password
</label>
<input
type='password'
id='password'
name='password'
placeholder='Password'
class='mt-1 input-field'
autocomplete='off'
required
/>
</fieldset>
<section class='flex justify-center mt-8 mb-4'>
<button
type='submit'
class='button'
>
Verify Password
</button>
</section>
</form>
</section>
);
}

View File

@@ -30,7 +30,9 @@ export default function ListPhotos(
return ( return (
<article class='hover:opacity-70'> <article class='hover:opacity-70'>
<a <a
href={`/files/open/${file.file_name}?path=${file.parent_path}`} href={`/files/open/${encodeURIComponent(file.file_name)}?path=${
encodeURIComponent(file.parent_path)
}`}
class='flex items-center' class='flex items-center'
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
@@ -39,7 +41,9 @@ export default function ListPhotos(
? ( ? (
<video class='h-auto max-w-full rounded-md' title={file.file_name}> <video class='h-auto max-w-full rounded-md' title={file.file_name}>
<source <source
src={`/files/open/${file.file_name}?path=${file.parent_path}`} src={`/files/open/${encodeURIComponent(file.file_name)}?path=${
encodeURIComponent(file.parent_path)
}`}
type={`video/${extensionName}`} type={`video/${extensionName}`}
/> />
</video> </video>
@@ -48,7 +52,9 @@ export default function ListPhotos(
{isImage {isImage
? ( ? (
<img <img
src={`/photos/thumbnail/${file.file_name}?path=${file.parent_path}`} src={`/photos/thumbnail/${encodeURIComponent(file.file_name)}?path=${
encodeURIComponent(file.parent_path)
}`}
class='h-auto max-w-full rounded-md' class='h-auto max-w-full rounded-md'
alt={file.file_name} alt={file.file_name}
title={file.file_name} title={file.file_name}

View File

@@ -0,0 +1,22 @@
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE TABLE public.bewcloud_file_shares (
id uuid DEFAULT gen_random_uuid(),
user_id uuid DEFAULT gen_random_uuid(),
file_path text NOT NULL,
extra jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now()
);
ALTER TABLE ONLY public.bewcloud_file_shares ADD CONSTRAINT bewcloud_file_shares_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.bewcloud_file_shares ADD CONSTRAINT bewcloud_file_shares_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
ALTER TABLE ONLY public.bewcloud_file_shares ADD CONSTRAINT bewcloud_file_shares_user_id_file_path_unique UNIQUE (user_id, file_path);

View File

@@ -10,7 +10,14 @@
"update": "deno run -A -r https://fresh.deno.dev/update .", "update": "deno run -A -r https://fresh.deno.dev/update .",
"test": "deno test -A --check" "test": "deno test -A --check"
}, },
"fmt": { "useTabs": false, "lineWidth": 120, "indentWidth": 2, "singleQuote": true, "proseWrap": "preserve" }, "fmt": {
"useTabs": false,
"lineWidth": 120,
"indentWidth": 2,
"singleQuote": true,
"proseWrap": "preserve",
"exclude": ["README.md"]
},
"lint": { "lint": {
"rules": { "rules": {
"tags": ["fresh", "recommended"], "tags": ["fresh", "recommended"],

View File

@@ -1,6 +1,6 @@
services: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v2.1.0 image: ghcr.io/bewcloud/bewcloud:v2.2.0
restart: always restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000
@@ -10,7 +10,7 @@ services:
required: true required: true
volumes: volumes:
- ./data-files:/app/data-files - ./data-files:/app/data-files
# - ./bewcloud.config.ts:/app/bewcloud.config.ts # uncomment if you need to override the default config - ./bewcloud.config.ts:/app/bewcloud.config.ts
postgresql: postgresql:
image: postgres:17 image: postgres:17

View File

@@ -25,15 +25,19 @@ import * as $api_expenses_import_expenses from './routes/api/expenses/import-exp
import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx'; import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx';
import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx'; import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx';
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx'; import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
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 from './routes/api/files/delete.tsx'; import * as $api_files_delete from './routes/api/files/delete.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 from './routes/api/files/get.tsx'; import * as $api_files_get from './routes/api/files/get.tsx';
import * as $api_files_move_directory from './routes/api/files/move-directory.tsx'; import * as $api_files_move_directory from './routes/api/files/move-directory.tsx';
import * as $api_files_move from './routes/api/files/move.tsx'; import * as $api_files_move from './routes/api/files/move.tsx';
import * as $api_files_rename_directory from './routes/api/files/rename-directory.tsx'; import * as $api_files_rename_directory from './routes/api/files/rename-directory.tsx';
import * as $api_files_rename from './routes/api/files/rename.tsx'; import * as $api_files_rename from './routes/api/files/rename.tsx';
import * as $api_files_search from './routes/api/files/search.tsx'; import * as $api_files_search from './routes/api/files/search.tsx';
import * as $api_files_update_share from './routes/api/files/update-share.tsx';
import * as $api_files_upload from './routes/api/files/upload.tsx'; import * as $api_files_upload from './routes/api/files/upload.tsx';
import * as $api_news_add_feed from './routes/api/news/add-feed.tsx'; import * as $api_news_add_feed from './routes/api/news/add-feed.tsx';
import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx'; import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx';
@@ -44,6 +48,9 @@ import * as $api_notes_save from './routes/api/notes/save.tsx';
import * as $dashboard from './routes/dashboard.tsx'; import * as $dashboard from './routes/dashboard.tsx';
import * as $dav from './routes/dav.tsx'; import * as $dav from './routes/dav.tsx';
import * as $expenses from './routes/expenses.tsx'; import * as $expenses from './routes/expenses.tsx';
import * as $file_share_fileShareId_ from './routes/file-share/[fileShareId].tsx';
import * as $file_share_fileShareId_open_fileName_ from './routes/file-share/[fileShareId]/open/[fileName].tsx';
import * as $file_share_fileShareId_verify from './routes/file-share/[fileShareId]/verify.tsx';
import * as $files from './routes/files.tsx'; import * as $files from './routes/files.tsx';
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx'; import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
import * as $index from './routes/index.tsx'; import * as $index from './routes/index.tsx';
@@ -98,15 +105,19 @@ const manifest = {
'./routes/api/expenses/update-budget.tsx': $api_expenses_update_budget, './routes/api/expenses/update-budget.tsx': $api_expenses_update_budget,
'./routes/api/expenses/update-expense.tsx': $api_expenses_update_expense, './routes/api/expenses/update-expense.tsx': $api_expenses_update_expense,
'./routes/api/files/create-directory.tsx': $api_files_create_directory, './routes/api/files/create-directory.tsx': $api_files_create_directory,
'./routes/api/files/create-share.tsx': $api_files_create_share,
'./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.tsx': $api_files_delete, './routes/api/files/delete.tsx': $api_files_delete,
'./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.tsx': $api_files_get, './routes/api/files/get.tsx': $api_files_get,
'./routes/api/files/move-directory.tsx': $api_files_move_directory, './routes/api/files/move-directory.tsx': $api_files_move_directory,
'./routes/api/files/move.tsx': $api_files_move, './routes/api/files/move.tsx': $api_files_move,
'./routes/api/files/rename-directory.tsx': $api_files_rename_directory, './routes/api/files/rename-directory.tsx': $api_files_rename_directory,
'./routes/api/files/rename.tsx': $api_files_rename, './routes/api/files/rename.tsx': $api_files_rename,
'./routes/api/files/search.tsx': $api_files_search, './routes/api/files/search.tsx': $api_files_search,
'./routes/api/files/update-share.tsx': $api_files_update_share,
'./routes/api/files/upload.tsx': $api_files_upload, './routes/api/files/upload.tsx': $api_files_upload,
'./routes/api/news/add-feed.tsx': $api_news_add_feed, './routes/api/news/add-feed.tsx': $api_news_add_feed,
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed, './routes/api/news/delete-feed.tsx': $api_news_delete_feed,
@@ -117,6 +128,9 @@ const manifest = {
'./routes/dashboard.tsx': $dashboard, './routes/dashboard.tsx': $dashboard,
'./routes/dav.tsx': $dav, './routes/dav.tsx': $dav,
'./routes/expenses.tsx': $expenses, './routes/expenses.tsx': $expenses,
'./routes/file-share/[fileShareId].tsx': $file_share_fileShareId_,
'./routes/file-share/[fileShareId]/open/[fileName].tsx': $file_share_fileShareId_open_fileName_,
'./routes/file-share/[fileShareId]/verify.tsx': $file_share_fileShareId_verify,
'./routes/files.tsx': $files, './routes/files.tsx': $files,
'./routes/files/open/[fileName].tsx': $files_open_fileName_, './routes/files/open/[fileName].tsx': $files_open_fileName_,
'./routes/index.tsx': $index, './routes/index.tsx': $index,

View File

@@ -6,11 +6,13 @@ interface FilesWrapperProps {
initialFiles: DirectoryFile[]; initialFiles: DirectoryFile[];
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean;
fileShareId?: string;
} }
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself // This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function FilesWrapper( export default function FilesWrapper(
{ initialDirectories, initialFiles, initialPath, baseUrl }: FilesWrapperProps, { initialDirectories, initialFiles, initialPath, baseUrl, isFileSharingAllowed, fileShareId }: FilesWrapperProps,
) { ) {
return ( return (
<MainFiles <MainFiles
@@ -18,6 +20,8 @@ export default function FilesWrapper(
initialFiles={initialFiles} initialFiles={initialFiles}
initialPath={initialPath} initialPath={initialPath}
baseUrl={baseUrl} baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed}
fileShareId={fileShareId}
/> />
); );
} }

View File

@@ -30,7 +30,7 @@ export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data);
export const generateKey = async (key: string): Promise<CryptoKey> => export const generateKey = async (key: string): Promise<CryptoKey> =>
await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
async function signAuthJwt(key: CryptoKey, data: JwtData): Promise<string> { async function signAuthJwt<T = JwtData>(key: CryptoKey, data: T): Promise<string> {
const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' + const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' +
encodeBase64Url(textToData(JSON.stringify(data) || '')); encodeBase64Url(textToData(JSON.stringify(data) || ''));
const signature = encodeBase64Url( const signature = encodeBase64Url(
@@ -39,7 +39,7 @@ async function signAuthJwt(key: CryptoKey, data: JwtData): Promise<string> {
return `${payload}.${signature}`; return `${payload}.${signature}`;
} }
export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise<JwtData> { export async function verifyAuthJwt<T = JwtData>(key: CryptoKey, jwt: string): Promise<T> {
const jwtParts = jwt.split('.'); const jwtParts = jwt.split('.');
if (jwtParts.length !== 3) { if (jwtParts.length !== 3) {
throw new Error('Malformed JWT'); throw new Error('Malformed JWT');
@@ -47,7 +47,7 @@ export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise<JwtDat
const data = textToData(jwtParts[0] + '.' + jwtParts[1]); const data = textToData(jwtParts[0] + '.' + jwtParts[1]);
if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) { if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) {
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as JwtData; return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as T;
} }
throw new Error('Invalid JWT'); throw new Error('Invalid JWT');
@@ -145,10 +145,10 @@ async function getDataFromCookie(
return null; return null;
} }
export async function generateToken(tokenData: JwtData['data']): Promise<string> { export async function generateToken<T = JwtData>(tokenData: T): Promise<string> {
const key = await generateKey(JWT_SECRET); const key = await generateKey(JWT_SECRET);
const token = await signAuthJwt(key, { data: tokenData }); const token = await signAuthJwt<{ data: T }>(key, { data: tokenData });
return token; return token;
} }
@@ -243,31 +243,3 @@ export async function createSessionCookie(
return response; return response;
} }
export async function updateSessionCookie(
response: Response,
request: Request,
userSession: UserSession,
newSessionData: JwtData['data'],
) {
const token = await generateToken(newSessionData);
const cookie: Cookie = {
name: COOKIE_NAME,
value: token,
expires: userSession.expires_at,
path: '/',
secure: isRunningLocally(request) ? false : true,
httpOnly: true,
sameSite: 'Lax',
domain: await resolveCookieDomain(request),
};
if (await AppConfig.isCookieDomainSecurityDisabled()) {
delete cookie.domain;
}
setCookie(response.headers, cookie);
return response;
}

View File

@@ -21,6 +21,7 @@ export class AppConfig {
}, },
files: { files: {
rootPath: 'data-files', rootPath: 'data-files',
allowPublicSharing: false,
}, },
core: { core: {
enabledApps: ['news', 'notes', 'photos', 'expenses'], enabledApps: ['news', 'notes', 'photos', 'expenses'],
@@ -156,6 +157,12 @@ export class AppConfig {
return this.config.auth.enableSingleSignOn; return this.config.auth.enableSingleSignOn;
} }
static async isPublicFileSharingAllowed(): Promise<boolean> {
await this.loadConfig();
return this.config.files.allowPublicSharing;
}
static async getFilesRootPath(): Promise<string> { static async getFilesRootPath(): Promise<string> {
await this.loadConfig(); await this.loadConfig();

View File

@@ -1,10 +1,25 @@
import { join } from 'std/path/join.ts'; import { join } from 'std/path/join.ts';
import { resolve } from 'std/path/resolve.ts'; import { resolve } from 'std/path/resolve.ts';
import { lookup } from 'mrmime'; import { lookup } from 'mrmime';
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
import Database, { sql } from '/lib/interfaces/database.ts';
import {
COOKIE_NAME as AUTH_COOKIE_NAME,
generateKey,
generateToken,
JWT_SECRET,
resolveCookieDomain,
verifyAuthJwt,
} from '/lib/auth.ts';
import { isRunningLocally } from '/lib/utils/misc.ts';
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
const db = new Database();
export class DirectoryModel { export class DirectoryModel {
static async list(userId: string, path: string): Promise<Directory[]> { static async list(userId: string, path: string): Promise<Directory[]> {
@@ -18,6 +33,10 @@ export class DirectoryModel {
entry.isDirectory || entry.isSymlink entry.isDirectory || entry.isSymlink
); );
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
? await FileShareModel.getByParentFilePath(userId, path)
: [];
for (const entry of directoryEntries) { for (const entry of directoryEntries) {
const stat = await Deno.stat(join(rootPath, entry.name)); const stat = await Deno.stat(join(rootPath, entry.name));
@@ -27,6 +46,7 @@ export class DirectoryModel {
directory_name: entry.name, directory_name: entry.name,
has_write_access: true, has_write_access: true,
size_in_bytes: stat.size, size_in_bytes: stat.size,
file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
}; };
@@ -110,6 +130,10 @@ export class DirectoryModel {
const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean); const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean);
for (const relativeDirectoryPath of matchingDirectories) { for (const relativeDirectoryPath of matchingDirectories) {
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
? await FileShareModel.getByParentFilePath(userId, relativeDirectoryPath)
: [];
const stat = await Deno.stat(join(rootPath, relativeDirectoryPath)); const stat = await Deno.stat(join(rootPath, relativeDirectoryPath));
let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`; let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
const directoryName = relativeDirectoryPath.split('/').pop()!; const directoryName = relativeDirectoryPath.split('/').pop()!;
@@ -124,6 +148,9 @@ export class DirectoryModel {
directory_name: directoryName, directory_name: directoryName,
has_write_access: true, has_write_access: true,
size_in_bytes: stat.size, size_in_bytes: stat.size,
file_share_id: fileShares.find((fileShare) =>
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
)?.id || null,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
}; };
@@ -150,6 +177,10 @@ export class FileModel {
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile); const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
? await FileShareModel.getByParentFilePath(userId, path)
: [];
for (const entry of fileEntries) { for (const entry of fileEntries) {
const stat = await Deno.stat(join(rootPath, entry.name)); const stat = await Deno.stat(join(rootPath, entry.name));
@@ -159,6 +190,7 @@ export class FileModel {
file_name: entry.name, file_name: entry.name,
has_write_access: true, has_write_access: true,
size_in_bytes: stat.size, size_in_bytes: stat.size,
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(path, entry.name))?.id || null,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
}; };
@@ -315,6 +347,10 @@ export class FileModel {
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
for (const relativeFilePath of matchingFiles) { for (const relativeFilePath of matchingFiles) {
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
? await FileShareModel.getByParentFilePath(userId, relativeFilePath)
: [];
const stat = await Deno.stat(join(rootPath, relativeFilePath)); const stat = await Deno.stat(join(rootPath, relativeFilePath));
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
const fileName = relativeFilePath.split('/').pop()!; const fileName = relativeFilePath.split('/').pop()!;
@@ -329,6 +365,8 @@ export class FileModel {
file_name: fileName, file_name: fileName,
has_write_access: true, has_write_access: true,
size_in_bytes: stat.size, size_in_bytes: stat.size,
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(relativeFilePath, fileName))?.id ||
null,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
}; };
@@ -384,6 +422,10 @@ export class FileModel {
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
for (const relativeFilePath of matchingFiles) { for (const relativeFilePath of matchingFiles) {
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
? await FileShareModel.getByParentFilePath(userId, relativeFilePath)
: [];
const stat = await Deno.stat(join(rootPath, relativeFilePath)); const stat = await Deno.stat(join(rootPath, relativeFilePath));
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
const fileName = relativeFilePath.split('/').pop()!; const fileName = relativeFilePath.split('/').pop()!;
@@ -398,6 +440,8 @@ export class FileModel {
file_name: fileName, file_name: fileName,
has_write_access: true, has_write_access: true,
size_in_bytes: stat.size, size_in_bytes: stat.size,
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(relativeFilePath, fileName))?.id ||
null,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
}; };
@@ -414,6 +458,133 @@ export class FileModel {
} }
} }
export interface FileShareJwtData {
data: {
file_share_id: string;
hashed_password: string;
};
}
export class FileShareModel {
static async getById(id: string): Promise<FileShare | null> {
const fileShare = (await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares" WHERE "id" = $1 LIMIT 1`, [
id,
]))[0];
return fileShare;
}
static async getByParentFilePath(userId: string, parentFilePath: string): Promise<FileShare[]> {
const fileShares = await db.query<FileShare>(
sql`SELECT * FROM "bewcloud_file_shares" WHERE "user_id" = $1 AND "file_path" LIKE $2`,
[userId, `${parentFilePath}%`],
);
return fileShares;
}
static async create(fileShare: Omit<FileShare, 'id' | 'created_at'>): Promise<FileShare> {
const newFileShare = (await db.query<FileShare>(
sql`INSERT INTO "bewcloud_file_shares" (
"user_id",
"file_path",
"extra"
) VALUES ($1, $2, $3)
RETURNING *`,
[
fileShare.user_id,
fileShare.file_path,
JSON.stringify(fileShare.extra),
],
))[0];
return newFileShare;
}
static async update(fileShare: FileShare): Promise<void> {
await db.query(
sql`UPDATE "bewcloud_file_shares" SET "extra" = $2 WHERE "id" = $1`,
[fileShare.id, JSON.stringify(fileShare.extra)],
);
}
static async delete(fileShareId: string): Promise<void> {
await db.query(
sql`DELETE FROM "bewcloud_file_shares" WHERE "id" = $1`,
[fileShareId],
);
}
static async createSessionCookie(
request: Request,
response: Response,
fileShareId: string,
hashedPassword: string,
) {
const token = await generateToken<FileShareJwtData['data']>({
file_share_id: fileShareId,
hashed_password: hashedPassword,
});
const cookie: Cookie = {
name: COOKIE_NAME,
value: token,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
path: `/file-share/${fileShareId}`,
secure: isRunningLocally(request) ? false : true,
httpOnly: true,
sameSite: 'Lax',
domain: await resolveCookieDomain(request),
};
if (await AppConfig.isCookieDomainSecurityDisabled()) {
delete cookie.domain;
}
setCookie(response.headers, cookie);
return response;
}
static async getDataFromRequest(request: Request): Promise<{ fileShareId: string; hashedPassword: string } | null> {
const cookies = getCookies(request.headers);
if (cookies[COOKIE_NAME]) {
const result = await this.getDataFromCookie(cookies[COOKIE_NAME]);
if (result) {
return result;
}
}
return null;
}
private static async getDataFromCookie(
cookieValue: string,
): Promise<{ fileShareId: string; hashedPassword: string } | null> {
if (!cookieValue) {
return null;
}
const key = await generateKey(JWT_SECRET);
try {
const token = await verifyAuthJwt<FileShareJwtData>(key, cookieValue);
if (!token.data.file_share_id || !token.data.hashed_password) {
throw new Error('Not Found');
}
return { fileShareId: token.data.file_share_id, hashedPassword: token.data.hashed_password };
} catch (error) {
console.error(error);
}
return null;
}
}
/** /**
* Ensures the user path is valid and securely accessible (meaning it's not trying to access files outside of the user's root directory). * Ensures the user path is valid and securely accessible (meaning it's not trying to access files outside of the user's root directory).
* Does not check if the path exists. * Does not check if the path exists.
@@ -433,6 +604,34 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string,
} }
} }
/**
* Ensures the file share path is valid and securely accessible (meaning it's not trying to access files outside of the file share's root directory).
* Does not check if the path exists.
*
* @param userId - The user ID
* @param fileSharePath - The file share path
* @param path - The relative path (user-provided) to check
*/
export async function ensureFileSharePathIsValidAndSecurelyAccessible(
userId: string,
fileSharePath: string,
path: string,
): Promise<void> {
await ensureUserPathIsValidAndSecurelyAccessible(userId, fileSharePath);
const userRootPath = join(await AppConfig.getFilesRootPath(), userId, '/');
const fileShareRootPath = join(userRootPath, fileSharePath);
const fullPath = join(fileShareRootPath, path);
const resolvedFullPath = `${resolve(fullPath)}/`;
if (!resolvedFullPath.startsWith(fileShareRootPath)) {
throw new Error('Invalid file path');
}
}
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> { async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
await ensureUserPathIsValidAndSecurelyAccessible(userId, path); await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
@@ -536,3 +735,16 @@ export async function searchFilesAndDirectories(
files, files,
}; };
} }
export async function getPathInfo(userId: string, path: string): Promise<{ isDirectory: boolean; isFile: boolean }> {
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(await AppConfig.getFilesRootPath(), userId);
const stat = await Deno.stat(join(rootPath, path));
return {
isDirectory: stat.isDirectory,
isFile: stat.isFile,
};
}

View File

@@ -93,6 +93,7 @@ export interface Directory {
directory_name: string; directory_name: string;
has_write_access: boolean; has_write_access: boolean;
size_in_bytes: number; size_in_bytes: number;
file_share_id: string | null;
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;
} }
@@ -103,6 +104,7 @@ export interface DirectoryFile {
file_name: string; file_name: string;
has_write_access: boolean; has_write_access: boolean;
size_in_bytes: number; size_in_bytes: number;
file_share_id: string | null;
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;
} }
@@ -175,6 +177,8 @@ export interface Config {
files: { files: {
/** The root-relative root path for files, i.e. "data-files" */ /** The root-relative root path for files, i.e. "data-files" */
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) */
allowPublicSharing: boolean;
}; };
core: { core: {
/** dashboard and files cannot be disabled */ /** dashboard and files cannot be disabled */
@@ -221,3 +225,13 @@ export interface MultiFactorAuthMethod {
}; };
}; };
} }
export interface FileShare {
id: string;
user_id: string;
file_path: string;
extra: {
hashed_password?: string;
};
created_at: Date;
}

View File

@@ -0,0 +1,86 @@
import { Handlers } from 'fresh/server.ts';
import { Directory, DirectoryFile, FileShare, FreshContextState } from '/lib/types.ts';
import { DirectoryModel, FileModel, FileShareModel, getPathInfo } from '/lib/models/files.ts';
import { generateHash } from '/lib/utils/misc.ts';
import { PASSWORD_SALT } from '/lib/auth.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export interface RequestBody {
pathInView: string;
filePath: string;
password?: string;
}
export interface ResponseBody {
success: boolean;
newFiles: DirectoryFile[];
newDirectories: Directory[];
createdFileShareId: string;
}
export const handler: Handlers<Data, FreshContextState> = {
async POST(request, context) {
if (!context.state.user) {
return new Response('Unauthorized', { status: 401 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Forbidden', { status: 403 });
}
const requestBody = await request.clone().json() as RequestBody;
if (
!requestBody.filePath || !requestBody.pathInView || !requestBody.filePath.trim() ||
!requestBody.pathInView.trim() || !requestBody.filePath.startsWith('/') ||
requestBody.filePath.includes('../') || !requestBody.pathInView.startsWith('/') ||
requestBody.pathInView.includes('../')
) {
return new Response('Bad Request', { status: 400 });
}
// Confirm the file path belongs to the user
const { isDirectory, isFile } = await getPathInfo(
context.state.user.id,
requestBody.filePath,
);
// Confirm the file path ends with a / if it's a directory, and doesn't end with a / if it's a file
if (isDirectory && !requestBody.filePath.endsWith('/')) {
requestBody.filePath = `${requestBody.filePath}/`;
} else if (isFile && requestBody.filePath.endsWith('/')) {
requestBody.filePath = requestBody.filePath.slice(0, -1);
}
const extra: FileShare['extra'] = {};
if (requestBody.password) {
extra.hashed_password = await generateHash(`${requestBody.password}:${PASSWORD_SALT}`, 'SHA-256');
}
const fileShare: Omit<FileShare, 'id' | 'created_at'> = {
user_id: context.state.user.id,
file_path: requestBody.filePath,
extra,
};
const createdFileShare = await FileShareModel.create(fileShare);
const newFiles = await FileModel.list(context.state.user.id, requestBody.pathInView);
const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.pathInView);
const responseBody: ResponseBody = {
success: true,
newFiles,
newDirectories,
createdFileShareId: createdFileShare.id,
};
return new Response(JSON.stringify(responseBody));
},
};

View File

@@ -0,0 +1,56 @@
import { Handlers } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import { DirectoryModel, FileModel, FileShareModel } from '/lib/models/files.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export interface RequestBody {
pathInView: string;
fileShareId: string;
}
export interface ResponseBody {
success: boolean;
newFiles: DirectoryFile[];
newDirectories: Directory[];
}
export const handler: Handlers<Data, FreshContextState> = {
async POST(request, context) {
if (!context.state.user) {
return new Response('Unauthorized', { status: 401 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Forbidden', { status: 403 });
}
const requestBody = await request.clone().json() as RequestBody;
if (
!requestBody.fileShareId || !requestBody.pathInView || !requestBody.pathInView.trim() ||
!requestBody.pathInView.startsWith('/') || requestBody.pathInView.includes('../')
) {
return new Response('Bad Request', { status: 400 });
}
const fileShare = await FileShareModel.getById(requestBody.fileShareId);
if (!fileShare || fileShare.user_id !== context.state.user.id) {
return new Response('Not Found', { status: 404 });
}
await FileShareModel.delete(requestBody.fileShareId);
const newFiles = await FileModel.list(context.state.user.id, requestBody.pathInView);
const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.pathInView);
const responseBody: ResponseBody = { success: true, newFiles, newDirectories };
return new Response(JSON.stringify(responseBody));
},
};

View File

@@ -0,0 +1,46 @@
import { Handlers } from 'fresh/server.ts';
import { FileShare, FreshContextState } from '/lib/types.ts';
import { FileShareModel } from '/lib/models/files.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export interface RequestBody {
fileShareId: string;
}
export interface ResponseBody {
success: boolean;
fileShare: FileShare;
}
export const handler: Handlers<Data, FreshContextState> = {
async POST(request, context) {
if (!context.state.user) {
return new Response('Unauthorized', { status: 401 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Forbidden', { status: 403 });
}
const requestBody = await request.clone().json() as RequestBody;
if (!requestBody.fileShareId) {
return new Response('Bad Request', { status: 400 });
}
const fileShare = await FileShareModel.getById(requestBody.fileShareId);
if (!fileShare || fileShare.user_id !== context.state.user.id) {
return new Response('Not Found', { status: 404 });
}
const responseBody: ResponseBody = { success: true, fileShare };
return new Response(JSON.stringify(responseBody));
},
};

View File

@@ -0,0 +1,65 @@
import { Handlers } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import { DirectoryModel, FileModel, FileShareModel } from '/lib/models/files.ts';
import { generateHash } from '/lib/utils/misc.ts';
import { PASSWORD_SALT } from '/lib/auth.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export interface RequestBody {
pathInView: string;
fileShareId: string;
password?: string;
}
export interface ResponseBody {
success: boolean;
newFiles: DirectoryFile[];
newDirectories: Directory[];
}
export const handler: Handlers<Data, FreshContextState> = {
async POST(request, context) {
if (!context.state.user) {
return new Response('Unauthorized', { status: 401 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Forbidden', { status: 403 });
}
const requestBody = await request.clone().json() as RequestBody;
if (
!requestBody.fileShareId || !requestBody.pathInView || !requestBody.pathInView.trim() ||
!requestBody.pathInView.startsWith('/') || requestBody.pathInView.includes('../')
) {
return new Response('Bad Request', { status: 400 });
}
const fileShare = await FileShareModel.getById(requestBody.fileShareId);
if (!fileShare || fileShare.user_id !== context.state.user.id) {
return new Response('Not Found', { status: 404 });
}
if (requestBody.password) {
fileShare.extra.hashed_password = await generateHash(`${requestBody.password}:${PASSWORD_SALT}`, 'SHA-256');
} else {
delete fileShare.extra.hashed_password;
}
await FileShareModel.update(fileShare);
const newFiles = await FileModel.list(context.state.user.id, requestBody.pathInView);
const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.pathInView);
const responseBody: ResponseBody = { success: true, newFiles, newDirectories };
return new Response(JSON.stringify(responseBody));
},
};

View File

@@ -0,0 +1,133 @@
import { Handlers, PageProps } from 'fresh/server.ts';
import { join } from 'std/path/join.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import {
DirectoryModel,
ensureFileSharePathIsValidAndSecurelyAccessible,
FileModel,
FileShareModel,
} from '/lib/models/files.ts';
import { AppConfig } from '/lib/config.ts';
import FilesWrapper from '/islands/files/FilesWrapper.tsx';
interface Data {
shareDirectories: Directory[];
shareFiles: DirectoryFile[];
currentPath: string;
baseUrl: string;
fileShareId?: string;
}
export const handler: Handlers<Data, FreshContextState> = {
async GET(request, context) {
const { fileShareId } = context.params;
if (!fileShareId) {
return new Response('Not Found', { status: 404 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Not Found', { status: 404 });
}
const baseUrl = (await AppConfig.getConfig()).auth.baseUrl;
const fileShare = await FileShareModel.getById(fileShareId);
if (!fileShare) {
return new Response('Not Found', { status: 404 });
}
const searchParams = new URL(request.url).searchParams;
let currentPath = searchParams.get('path') || '/';
// Send invalid paths back to root
if (!currentPath.startsWith('/') || currentPath.includes('../')) {
currentPath = '/';
}
// Always append a trailing slash
if (!currentPath.endsWith('/')) {
currentPath = `${currentPath}/`;
}
// Confirm that currentPath is not _outside_ the fileShare.file_path
await ensureFileSharePathIsValidAndSecurelyAccessible(fileShare.user_id, fileShare.file_path, currentPath);
const isFileSharePathDirectory = fileShare.file_path.endsWith('/');
currentPath = isFileSharePathDirectory ? join(fileShare.file_path, currentPath) : fileShare.file_path;
const isFilePathDirectory = currentPath.endsWith('/');
const fileSharePathDirectory = isFileSharePathDirectory
? fileShare.file_path
: `${fileShare.file_path.split('/').slice(0, -1).join('/')}/`;
const filePathDirectory = isFilePathDirectory ? currentPath : `${currentPath.split('/').slice(0, -1).join('/')}/`;
// Does the file share require a password? If so, redirect to the verification page
if (fileShare.extra.hashed_password) {
const { fileShareId: fileShareIdFromSession, hashedPassword: hashedPasswordFromSession } =
(await FileShareModel.getDataFromRequest(request)) || {};
if (
!fileShareIdFromSession || fileShareIdFromSession !== fileShareId ||
hashedPasswordFromSession !== fileShare.extra.hashed_password
) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/file-share/${fileShareId}/verify` } });
}
}
let shareDirectories = await DirectoryModel.list(
fileShare.user_id,
isFilePathDirectory ? currentPath : filePathDirectory,
);
let shareFiles = await FileModel.list(fileShare.user_id, isFilePathDirectory ? currentPath : filePathDirectory);
if (!isFileSharePathDirectory) {
shareDirectories = shareDirectories.filter((directory) =>
directory.directory_name === currentPath.split('/').pop()
);
shareFiles = shareFiles.filter((file) => file.file_name === currentPath.split('/').pop());
}
// Remove the filePathDirectory from the directories' paths, and set has_write_access to false
shareDirectories = shareDirectories.map((directory) => ({
...directory,
has_write_access: false,
parent_path: directory.parent_path.replace(fileSharePathDirectory, '/'),
}));
// Remove the filePathDirectory from the files' paths, and set has_write_access to false
shareFiles = shareFiles.map((file) => ({
...file,
has_write_access: false,
parent_path: file.parent_path.replace(fileSharePathDirectory, '/'),
}));
const publicCurrentPath = currentPath.replace(fileShare.file_path, '/');
return await context.render({ shareDirectories, shareFiles, currentPath: publicCurrentPath, baseUrl, fileShareId });
},
};
export default function FilesPage({ data }: PageProps<Data, FreshContextState>) {
return (
<main>
<FilesWrapper
initialDirectories={data.shareDirectories}
initialFiles={data.shareFiles}
initialPath={data.currentPath}
baseUrl={data.baseUrl}
isFileSharingAllowed
fileShareId={data.fileShareId}
/>
</main>
);
}

View File

@@ -0,0 +1,82 @@
import { Handlers } from 'fresh/server.ts';
import { join } from 'std/path/join.ts';
import { FreshContextState } from '/lib/types.ts';
import { ensureFileSharePathIsValidAndSecurelyAccessible, FileModel, FileShareModel } from '/lib/models/files.ts';
import { AppConfig } from '/lib/config.ts';
interface Data {}
export const handler: Handlers<Data, FreshContextState> = {
async GET(request, context) {
const { fileShareId, fileName } = context.params;
if (!fileShareId || !fileName) {
return new Response('Not Found', { status: 404 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Not Found', { status: 404 });
}
const fileShare = await FileShareModel.getById(fileShareId);
if (!fileShare) {
return new Response('Not Found', { status: 404 });
}
if (fileShare.extra.hashed_password) {
const { fileShareId: fileShareIdFromSession, hashedPassword: hashedPasswordFromSession } =
(await FileShareModel.getDataFromRequest(request)) || {};
if (
!fileShareIdFromSession || fileShareIdFromSession !== fileShareId ||
hashedPasswordFromSession !== fileShare.extra.hashed_password
) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/file-share/${fileShareId}/verify` } });
}
}
const searchParams = new URL(request.url).searchParams;
let currentPath = searchParams.get('path') || '/';
// Send invalid paths back to root
if (!currentPath.startsWith('/') || currentPath.includes('../')) {
currentPath = '/';
}
// Always append a trailing slash
if (!currentPath.endsWith('/')) {
currentPath = `${currentPath}/`;
}
// Confirm that currentPath is not _outside_ the fileShare.file_path
await ensureFileSharePathIsValidAndSecurelyAccessible(fileShare.user_id, fileShare.file_path, currentPath);
const isFileSharePathDirectory = fileShare.file_path.endsWith('/');
const fileSharePathDirectory = isFileSharePathDirectory
? fileShare.file_path
: `${fileShare.file_path.split('/').slice(0, -1).join('/')}/`;
currentPath = isFileSharePathDirectory ? join(fileShare.file_path, currentPath) : fileSharePathDirectory;
const fileResult = await FileModel.get(fileShare.user_id, currentPath, decodeURIComponent(fileName));
if (!fileResult.success) {
return new Response('Not Found', { status: 404 });
}
return new Response(fileResult.contents!, {
status: 200,
headers: {
'cache-control': 'no-cache, no-store, must-revalidate',
'content-type': fileResult.contentType!,
'content-length': fileResult.byteSize!.toString(),
},
});
},
};

View File

@@ -0,0 +1,107 @@
import { Handlers, PageProps } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts';
import { getFormDataField } from '/lib/form-utils.tsx';
import { AppConfig } from '/lib/config.ts';
import { FileShareModel } from '/lib/models/files.ts';
import { generateHash } from '/lib/utils/misc.ts';
import { PASSWORD_SALT } from '/lib/auth.ts';
import ShareVerifyForm from '/components/files/ShareVerifyForm.tsx';
interface Data {
error?: {
title: string;
message: string;
};
}
export const handler: Handlers<Data, FreshContextState> = {
async GET(request, context) {
const { fileShareId } = context.params;
if (!fileShareId) {
return new Response('Not Found', { status: 404 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Not Found', { status: 404 });
}
const fileShare = await FileShareModel.getById(fileShareId);
if (!fileShare) {
return new Response('Not Found', { status: 404 });
}
if (!fileShare.extra.hashed_password) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/file-share/${fileShareId}` } });
}
return await context.render({});
},
async POST(request, context) {
const { fileShareId } = context.params;
if (!fileShareId) {
return new Response('Not Found', { status: 404 });
}
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
if (!isPublicFileSharingAllowed) {
return new Response('Not Found', { status: 404 });
}
const fileShare = await FileShareModel.getById(fileShareId);
if (!fileShare) {
return new Response('Not Found', { status: 404 });
}
if (!fileShare.extra.hashed_password) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/file-share/${fileShareId}` } });
}
try {
const formData = await request.formData();
const password = getFormDataField(formData, 'password');
if (!password) {
throw new Error('Password is required');
}
const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256');
if (hashedPassword !== fileShare.extra.hashed_password) {
throw new Error('Invalid password');
}
const response = new Response('Redirect', { status: 303, headers: { 'Location': `/file-share/${fileShareId}` } });
return await FileShareModel.createSessionCookie(request, response, fileShareId, hashedPassword);
} catch (error) {
console.error('File share verification error:', error);
return await context.render({
error: {
title: 'Verification Failed',
message: (error as Error).message,
},
});
}
},
};
export default function ShareVerifyPage({ data }: PageProps<Data, FreshContextState>) {
return (
<main>
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
<ShareVerifyForm
error={data.error}
/>
</section>
</main>
);
}

View File

@@ -10,6 +10,7 @@ interface Data {
userFiles: DirectoryFile[]; userFiles: DirectoryFile[];
currentPath: string; currentPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -38,7 +39,15 @@ 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);
return await context.render({ userDirectories, userFiles, currentPath, baseUrl }); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
return await context.render({
userDirectories,
userFiles,
currentPath,
baseUrl,
isFileSharingAllowed: isPublicFileSharingAllowed,
});
}, },
}; };
@@ -50,6 +59,7 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialFiles={data.userFiles} initialFiles={data.userFiles}
initialPath={data.currentPath} initialPath={data.currentPath}
baseUrl={data.baseUrl} baseUrl={data.baseUrl}
isFileSharingAllowed={data.isFileSharingAllowed}
/> />
</main> </main>
); );