From 7fac7febcfeabd542164f6af7c9402f5e46a0172 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Fri, 20 Jun 2025 12:04:16 +0100 Subject: [PATCH] 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 --- README.md | 16 +- bewcloud.config.sample.ts | 1 + components/auth/MultiFactorAuthVerifyForm.tsx | 6 +- components/files/CreateShareModal.tsx | 67 ++++ components/files/FilesBreadcrumb.tsx | 16 +- components/files/ListFiles.tsx | 102 ++++- components/files/ListPhotos.tsx | 6 +- components/files/MainFiles.tsx | 368 ++++++++++++++---- components/files/ManageShareModal.tsx | 121 ++++++ components/files/SearchFiles.tsx | 8 +- components/files/ShareVerifyForm.tsx | 58 +++ components/photos/ListPhotos.tsx | 12 +- db-migrations/004-public-file-sharing.pgsql | 22 ++ deno.json | 9 +- docker-compose.yml | 4 +- fresh.gen.ts | 14 + islands/files/FilesWrapper.tsx | 6 +- lib/auth.ts | 38 +- lib/config.ts | 7 + lib/models/files.ts | 214 +++++++++- lib/types.ts | 14 + routes/api/files/create-share.tsx | 86 ++++ routes/api/files/delete-share.tsx | 56 +++ routes/api/files/get-share.tsx | 46 +++ routes/api/files/update-share.tsx | 65 ++++ routes/file-share/[fileShareId].tsx | 133 +++++++ .../[fileShareId]/open/[fileName].tsx | 82 ++++ routes/file-share/[fileShareId]/verify.tsx | 107 +++++ routes/files.tsx | 12 +- 29 files changed, 1541 insertions(+), 155 deletions(-) create mode 100644 components/files/CreateShareModal.tsx create mode 100644 components/files/ManageShareModal.tsx create mode 100644 components/files/ShareVerifyForm.tsx create mode 100644 db-migrations/004-public-file-sharing.pgsql create mode 100644 routes/api/files/create-share.tsx create mode 100644 routes/api/files/delete-share.tsx create mode 100644 routes/api/files/get-share.tsx create mode 100644 routes/api/files/update-share.tsx create mode 100644 routes/file-share/[fileShareId].tsx create mode 100644 routes/file-share/[fileShareId]/open/[fileName].tsx create mode 100644 routes/file-share/[fileShareId]/verify.tsx diff --git a/README.md b/README.md index c7b4770..4af3dc6 100644 --- a/README.md +++ b/README.md @@ -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) +[![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: 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 -$ 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 $ 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) @@ -30,9 +37,6 @@ Alternatively, check the [Development section below](#development). > [!IMPORTANT] > 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 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). -## 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 /// ///` to create a shared directory between two users, and the same directory can have different names, now. diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index bef75c6..3dc82cb 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -13,6 +13,7 @@ const config: PartialDeep = { }, // 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: { // enabledApps: ['news', 'notes', 'photos', 'expenses'], // dashboard and files cannot be disabled diff --git a/components/auth/MultiFactorAuthVerifyForm.tsx b/components/auth/MultiFactorAuthVerifyForm.tsx index 821b28e..3f42987 100644 --- a/components/auth/MultiFactorAuthVerifyForm.tsx +++ b/components/auth/MultiFactorAuthVerifyForm.tsx @@ -28,9 +28,9 @@ export default function MultiFactorAuthVerifyForm( {error ? ( -
- {error.title}: - {error.message} +
+

{error.title}

+

{error.message}

) : null} diff --git a/components/files/CreateShareModal.tsx b/components/files/CreateShareModal.tsx new file mode 100644 index 0000000..87da611 --- /dev/null +++ b/components/files/CreateShareModal.tsx @@ -0,0 +1,67 @@ +import { useSignal } from '@preact/signals'; + +interface CreateShareModalProps { + isOpen: boolean; + filePath: string; + password?: string; + onClickSave: (filePath: string, password?: string) => Promise; + onClose: () => void; +} + +export default function CreateShareModal( + { isOpen, filePath, password, onClickSave, onClose }: CreateShareModalProps, +) { + const newPassword = useSignal(password || ''); + + return ( + <> +
+
+ +
+

Create New Public Share Link

+
+
+ + { + newPassword.value = event.currentTarget.value; + }} + autocomplete='off' + /> +
+
+
+ + +
+
+ + ); +} diff --git a/components/files/FilesBreadcrumb.tsx b/components/files/FilesBreadcrumb.tsx index 79c9b98..ea8212d 100644 --- a/components/files/FilesBreadcrumb.tsx +++ b/components/files/FilesBreadcrumb.tsx @@ -2,22 +2,24 @@ interface FilesBreadcrumbProps { path: string; isShowingNotes?: boolean; isShowingPhotos?: boolean; + fileShareId?: string; } -export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos }: FilesBreadcrumbProps) { - let routePath = 'files'; +export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos, fileShareId }: FilesBreadcrumbProps) { + let routePath = fileShareId ? `file-share/${fileShareId}` : 'files'; let rootPath = '/'; + let itemPluralLabel = 'files'; if (isShowingNotes) { routePath = 'notes'; + itemPluralLabel = 'notes'; rootPath = '/Notes/'; } else if (isShowingPhotos) { routePath = 'photos'; + itemPluralLabel = 'photos'; rootPath = '/Photos/'; } - const itemPluralLabel = routePath; - if (path === rootPath) { return (

@@ -30,7 +32,7 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos return (

- {!isShowingNotes && !isShowingPhotos ? All files : null} + {!isShowingNotes && !isShowingPhotos ? All files : null} {isShowingNotes ? All notes : null} {isShowingPhotos ? All photos : null} {pathParts.map((part, index) => { @@ -57,7 +59,9 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos return ( <> / - {decodeURIComponent(part)} + + {decodeURIComponent(part)} + ); })} diff --git a/components/files/ListFiles.tsx b/components/files/ListFiles.tsx index 7790c39..3c26f68 100644 --- a/components/files/ListFiles.tsx +++ b/components/files/ListFiles.tsx @@ -1,3 +1,5 @@ +import { join } from 'std/path/join.ts'; + import { Directory, DirectoryFile } from '/lib/types.ts'; import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts'; @@ -14,8 +16,11 @@ interface ListFilesProps { onClickOpenMoveFile?: (parentPath: string, name: string) => void; onClickDeleteDirectory?: (parentPath: string, name: string) => Promise; onClickDeleteFile?: (parentPath: string, name: string) => Promise; + onClickCreateShare?: (filePath: string) => void; + onClickOpenManageShare?: (fileShareId: string) => void; isShowingNotes?: boolean; isShowingPhotos?: boolean; + fileShareId?: string; } export default function ListFiles( @@ -32,8 +37,11 @@ export default function ListFiles( onClickOpenMoveFile, onClickDeleteDirectory, onClickDeleteFile, + onClickCreateShare, + onClickOpenManageShare, isShowingNotes, isShowingPhotos, + fileShareId, }: ListFilesProps, ) { const dateFormat = new Intl.DateTimeFormat('en-GB', { @@ -45,7 +53,7 @@ export default function ListFiles( minute: '2-digit', }); - let routePath = 'files'; + let routePath = fileShareId ? `file-share/${fileShareId}` : 'files'; let itemSingleLabel = 'file'; let itemPluralLabel = 'files'; @@ -81,7 +89,8 @@ export default function ListFiles( {(directories.length === 0 && files.length === 0) || - (typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined') + (typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined') || + fileShareId ? null : ( @@ -98,7 +107,9 @@ export default function ListFiles( {isShowingNotes || isShowingPhotos ? null : Size} - {isShowingPhotos ? null : } + {isShowingPhotos || fileShareId + ? null + : } @@ -107,7 +118,7 @@ export default function ListFiles( return ( - {typeof onClickChooseDirectory === 'undefined' ? null : ( + {typeof onClickChooseDirectory === 'undefined' || fileShareId ? null : ( {fullPath === TRASH_PATH ? null : ( )} - {isShowingPhotos ? null : ( + {isShowingPhotos || fileShareId ? null : ( {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || typeof onClickOpenMoveDirectory === 'undefined') ? null : ( -
+
)} @@ -203,7 +245,7 @@ export default function ListFiles( })} {files.map((file) => ( - {typeof onClickChooseFile === 'undefined' ? null : ( + {typeof onClickChooseFile === 'undefined' || fileShareId ? null : (
)} - {isShowingPhotos ? null : ( + {isShowingPhotos || fileShareId ? null : ( -
+
{typeof onClickOpenRenameFile === 'undefined' ? null : ( )} + {typeof onClickCreateShare === 'undefined' || file.file_share_id ? null : ( + + )} + {typeof onClickOpenManageShare === 'undefined' || !file.file_share_id ? null : ( + + )}
)} diff --git a/components/files/ListPhotos.tsx b/components/files/ListPhotos.tsx index e37b572..9a2cb8a 100644 --- a/components/files/ListPhotos.tsx +++ b/components/files/ListPhotos.tsx @@ -58,7 +58,7 @@ export default function ListFiles(
(false); const isUploading = useSignal(false); const isDeleting = useSignal(false); @@ -56,6 +74,8 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat const moveDirectoryOrFileModal = useSignal< { isOpen: boolean; isDirectory: boolean; path: string; name: string } | 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) { 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 ( <>
- + {!fileShareId ? : null} {isAnyItemChosen ? ( @@ -539,63 +697,67 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
- + -
-
- -
+ {!fileShareId + ? ( +
+
+ +
- -
+
+ + + +
+ +
+ ) + : null}
@@ -613,6 +775,9 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat onClickOpenMoveFile={onClickOpenMoveFile} onClickDeleteDirectory={onClickDeleteDirectory} onClickDeleteFile={onClickDeleteFile} + onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined} + onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined} + fileShareId={fileShareId} />
-
- WebDav URL:{' '} - {baseUrl}/dav -
+ {!fileShareId + ? ( +
+ WebDav URL:{' '} + {baseUrl}/dav +
+ ) + : null} - + {!fileShareId + ? ( + + ) + : null} - + {!fileShareId + ? ( + + ) + : null} - + {!fileShareId + ? ( + + ) + : null} + + {!fileShareId && isFileSharingAllowed + ? ( + + ) + : null} + + {!fileShareId && isFileSharingAllowed + ? ( + + ) + : null} ); } diff --git a/components/files/ManageShareModal.tsx b/components/files/ManageShareModal.tsx new file mode 100644 index 0000000..aec1e3d --- /dev/null +++ b/components/files/ManageShareModal.tsx @@ -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; + onClickDelete: (fileShareId: string) => Promise; + onClose: () => void; +} + +export default function ManageShareModal( + { baseUrl, isOpen, fileShareId, onClickSave, onClickDelete, onClose }: ManageShareModalProps, +) { + const newPassword = useSignal(''); + + const isLoading = useSignal(false); + const fileShare = useSignal(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 ( + <> +
+
+ +
+

Manage Public Share Link

+
+
+ Public Share URL:{' '} + {baseUrl}/file-share/{fileShareId} +
+
+ + { + newPassword.value = event.currentTarget.value; + }} + autocomplete='off' + /> +
+
+
+ + + +
+
+ + ); +} diff --git a/components/files/SearchFiles.tsx b/components/files/SearchFiles.tsx index 7e5511e..e0a2fc5 100644 --- a/components/files/SearchFiles.tsx +++ b/components/files/SearchFiles.tsx @@ -116,7 +116,9 @@ export default function SearchFiles({}: SearchFilesProps) { {matchingDirectories.value.map((directory) => (
  • (
  • +
    +

    + File Share Authentication +

    +

    + You are required to authenticate with a password +

    +
    + + {error + ? ( +
    +

    {error.title}

    +

    {error.message}

    +
    + ) + : null} + +
    +
    + + +
    + +
    + +
    +
    +
  • + ); +} diff --git a/components/photos/ListPhotos.tsx b/components/photos/ListPhotos.tsx index a4641e1..65a7ba4 100644 --- a/components/photos/ListPhotos.tsx +++ b/components/photos/ListPhotos.tsx @@ -30,7 +30,9 @@ export default function ListPhotos( return (
    @@ -48,7 +52,9 @@ export default function ListPhotos( {isImage ? ( {file.file_name} ); } diff --git a/lib/auth.ts b/lib/auth.ts index de59036..0dc8ecf 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -30,7 +30,7 @@ export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data); export const generateKey = async (key: string): Promise => await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); -async function signAuthJwt(key: CryptoKey, data: JwtData): Promise { +async function signAuthJwt(key: CryptoKey, data: T): Promise { const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' + encodeBase64Url(textToData(JSON.stringify(data) || '')); const signature = encodeBase64Url( @@ -39,7 +39,7 @@ async function signAuthJwt(key: CryptoKey, data: JwtData): Promise { return `${payload}.${signature}`; } -export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise { +export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise { const jwtParts = jwt.split('.'); if (jwtParts.length !== 3) { throw new Error('Malformed JWT'); @@ -47,7 +47,7 @@ export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise { +export async function generateToken(tokenData: T): Promise { const key = await generateKey(JWT_SECRET); - const token = await signAuthJwt(key, { data: tokenData }); + const token = await signAuthJwt<{ data: T }>(key, { data: tokenData }); return token; } @@ -243,31 +243,3 @@ export async function createSessionCookie( 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; -} diff --git a/lib/config.ts b/lib/config.ts index 9b93b4a..b187bb3 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -21,6 +21,7 @@ export class AppConfig { }, files: { rootPath: 'data-files', + allowPublicSharing: false, }, core: { enabledApps: ['news', 'notes', 'photos', 'expenses'], @@ -156,6 +157,12 @@ export class AppConfig { return this.config.auth.enableSingleSignOn; } + static async isPublicFileSharingAllowed(): Promise { + await this.loadConfig(); + + return this.config.files.allowPublicSharing; + } + static async getFilesRootPath(): Promise { await this.loadConfig(); diff --git a/lib/models/files.ts b/lib/models/files.ts index fc247e6..0ca1b84 100644 --- a/lib/models/files.ts +++ b/lib/models/files.ts @@ -1,10 +1,25 @@ import { join } from 'std/path/join.ts'; import { resolve } from 'std/path/resolve.ts'; import { lookup } from 'mrmime'; +import { Cookie, getCookies, setCookie } from 'std/http/cookie.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 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 { static async list(userId: string, path: string): Promise { @@ -18,6 +33,10 @@ export class DirectoryModel { entry.isDirectory || entry.isSymlink ); + const fileShares = (await AppConfig.isPublicFileSharingAllowed()) + ? await FileShareModel.getByParentFilePath(userId, path) + : []; + for (const entry of directoryEntries) { const stat = await Deno.stat(join(rootPath, entry.name)); @@ -27,6 +46,7 @@ export class DirectoryModel { directory_name: entry.name, has_write_access: true, 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(), created_at: stat.birthtime || new Date(), }; @@ -110,6 +130,10 @@ export class DirectoryModel { const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean); for (const relativeDirectoryPath of matchingDirectories) { + const fileShares = (await AppConfig.isPublicFileSharingAllowed()) + ? await FileShareModel.getByParentFilePath(userId, relativeDirectoryPath) + : []; + const stat = await Deno.stat(join(rootPath, relativeDirectoryPath)); let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`; const directoryName = relativeDirectoryPath.split('/').pop()!; @@ -124,6 +148,9 @@ export class DirectoryModel { directory_name: directoryName, has_write_access: true, 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(), created_at: stat.birthtime || new Date(), }; @@ -150,6 +177,10 @@ export class FileModel { 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) { const stat = await Deno.stat(join(rootPath, entry.name)); @@ -159,6 +190,7 @@ export class FileModel { file_name: entry.name, has_write_access: true, 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(), created_at: stat.birthtime || new Date(), }; @@ -315,6 +347,10 @@ export class FileModel { const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); for (const relativeFilePath of matchingFiles) { + const fileShares = (await AppConfig.isPublicFileSharingAllowed()) + ? await FileShareModel.getByParentFilePath(userId, relativeFilePath) + : []; + const stat = await Deno.stat(join(rootPath, relativeFilePath)); let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; const fileName = relativeFilePath.split('/').pop()!; @@ -329,6 +365,8 @@ export class FileModel { file_name: fileName, has_write_access: true, 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(), created_at: stat.birthtime || new Date(), }; @@ -384,6 +422,10 @@ export class FileModel { const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); for (const relativeFilePath of matchingFiles) { + const fileShares = (await AppConfig.isPublicFileSharingAllowed()) + ? await FileShareModel.getByParentFilePath(userId, relativeFilePath) + : []; + const stat = await Deno.stat(join(rootPath, relativeFilePath)); let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; const fileName = relativeFilePath.split('/').pop()!; @@ -398,6 +440,8 @@ export class FileModel { file_name: fileName, has_write_access: true, 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(), 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 { + const fileShare = (await db.query(sql`SELECT * FROM "bewcloud_file_shares" WHERE "id" = $1 LIMIT 1`, [ + id, + ]))[0]; + + return fileShare; + } + + static async getByParentFilePath(userId: string, parentFilePath: string): Promise { + const fileShares = await db.query( + sql`SELECT * FROM "bewcloud_file_shares" WHERE "user_id" = $1 AND "file_path" LIKE $2`, + [userId, `${parentFilePath}%`], + ); + + return fileShares; + } + + static async create(fileShare: Omit): Promise { + const newFileShare = (await db.query( + 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 { + 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 { + 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({ + 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(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). * 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 { + 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 { await ensureUserPathIsValidAndSecurelyAccessible(userId, path); @@ -536,3 +735,16 @@ export async function searchFilesAndDirectories( 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, + }; +} diff --git a/lib/types.ts b/lib/types.ts index 4778ec9..f67627b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -93,6 +93,7 @@ export interface Directory { directory_name: string; has_write_access: boolean; size_in_bytes: number; + file_share_id: string | null; updated_at: Date; created_at: Date; } @@ -103,6 +104,7 @@ export interface DirectoryFile { file_name: string; has_write_access: boolean; size_in_bytes: number; + file_share_id: string | null; updated_at: Date; created_at: Date; } @@ -175,6 +177,8 @@ export interface Config { files: { /** The root-relative root path for files, i.e. "data-files" */ 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: { /** 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; +} diff --git a/routes/api/files/create-share.tsx b/routes/api/files/create-share.tsx new file mode 100644 index 0000000..cc9a83a --- /dev/null +++ b/routes/api/files/create-share.tsx @@ -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 = { + 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 = { + 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)); + }, +}; diff --git a/routes/api/files/delete-share.tsx b/routes/api/files/delete-share.tsx new file mode 100644 index 0000000..c048d47 --- /dev/null +++ b/routes/api/files/delete-share.tsx @@ -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 = { + 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)); + }, +}; diff --git a/routes/api/files/get-share.tsx b/routes/api/files/get-share.tsx new file mode 100644 index 0000000..2a6d3ff --- /dev/null +++ b/routes/api/files/get-share.tsx @@ -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 = { + 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)); + }, +}; diff --git a/routes/api/files/update-share.tsx b/routes/api/files/update-share.tsx new file mode 100644 index 0000000..2941ebd --- /dev/null +++ b/routes/api/files/update-share.tsx @@ -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 = { + 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)); + }, +}; diff --git a/routes/file-share/[fileShareId].tsx b/routes/file-share/[fileShareId].tsx new file mode 100644 index 0000000..b44a67c --- /dev/null +++ b/routes/file-share/[fileShareId].tsx @@ -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 = { + 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) { + return ( +
    + +
    + ); +} diff --git a/routes/file-share/[fileShareId]/open/[fileName].tsx b/routes/file-share/[fileShareId]/open/[fileName].tsx new file mode 100644 index 0000000..71d5de8 --- /dev/null +++ b/routes/file-share/[fileShareId]/open/[fileName].tsx @@ -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 = { + 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(), + }, + }); + }, +}; diff --git a/routes/file-share/[fileShareId]/verify.tsx b/routes/file-share/[fileShareId]/verify.tsx new file mode 100644 index 0000000..ffb851c --- /dev/null +++ b/routes/file-share/[fileShareId]/verify.tsx @@ -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 = { + 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) { + return ( +
    +
    + +
    +
    + ); +} diff --git a/routes/files.tsx b/routes/files.tsx index bcd6d7d..dd37e77 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -10,6 +10,7 @@ interface Data { userFiles: DirectoryFile[]; currentPath: string; baseUrl: string; + isFileSharingAllowed: boolean; } export const handler: Handlers = { @@ -38,7 +39,15 @@ export const handler: Handlers = { 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) initialFiles={data.userFiles} initialPath={data.currentPath} baseUrl={data.baseUrl} + isFileSharingAllowed={data.isFileSharingAllowed} /> );