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

@@ -28,9 +28,9 @@ export default function MultiFactorAuthVerifyForm(
{error
? (
<section class='bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded relative mb-4'>
<strong class='font-bold'>{error.title}:</strong>
<span class='block sm:inline'>{error.message}</span>
<section class='notification-error'>
<h3>{error.title}</h3>
<p>{error.message}</p>
</section>
)
: 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;
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 (
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
@@ -30,7 +32,7 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos
return (
<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}
{isShowingPhotos ? <a href={`/photos?path=/Photos/`}>All photos</a> : null}
{pathParts.map((part, index) => {
@@ -57,7 +59,9 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos
return (
<>
<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 { 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<void>;
onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
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(
<thead>
<tr class='border-b border-slate-600'>
{(directories.length === 0 && files.length === 0) ||
(typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined')
(typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined') ||
fileShareId
? null
: (
<th scope='col' class='pl-6 pr-2 font-medium text-white w-3'>
@@ -98,7 +107,9 @@ export default function ListFiles(
{isShowingNotes || isShowingPhotos
? null
: <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>
</thead>
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
@@ -107,7 +118,7 @@ export default function ListFiles(
return (
<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'>
{fullPath === TRASH_PATH ? null : (
<input
@@ -124,7 +135,7 @@ export default function ListFiles(
)}
<td class='flex gap-3 px-6 py-4'>
<a
href={`/${routePath}?path=${fullPath}`}
href={`/${routePath}?path=${encodeURIComponent(fullPath)}`}
class='flex items-center font-normal text-white'
>
<img
@@ -146,13 +157,13 @@ export default function ListFiles(
-
</td>
)}
{isShowingPhotos ? null : (
{isShowingPhotos || fileShareId ? null : (
<td class='px-6 py-4'>
{(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' ||
typeof onClickOpenMoveDirectory === 'undefined')
? null
: (
<section class='flex items-center justify-end w-20'>
<section class='flex items-center justify-end w-24'>
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
@@ -168,7 +179,8 @@ export default function ListFiles(
</span>
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
onClick={() =>
onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
>
<img
src='/images/move.svg'
@@ -181,7 +193,7 @@ export default function ListFiles(
</span>
{typeof onClickDeleteDirectory === 'undefined' ? null : (
<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)}
>
<img
@@ -194,6 +206,36 @@ export default function ListFiles(
/>
</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>
)}
</td>
@@ -203,7 +245,7 @@ export default function ListFiles(
})}
{files.map((file) => (
<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'>
<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'
@@ -219,7 +261,9 @@ export default function ListFiles(
)}
<td class='flex gap-3 px-6 py-4'>
<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'
target='_blank'
rel='noopener noreferrer'
@@ -243,9 +287,9 @@ export default function ListFiles(
{humanFileSize(file.size_in_bytes)}
</td>
)}
{isShowingPhotos ? null : (
{isShowingPhotos || fileShareId ? null : (
<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 : (
<span
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 : (
<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)}
>
<img
@@ -291,6 +335,36 @@ export default function ListFiles(
/>
</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>
</td>
)}

View File

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

View File

@@ -21,21 +21,39 @@ import {
RequestBody as DeleteDirectoryRequestBody,
ResponseBody as DeleteDirectoryResponseBody,
} 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 ListFiles from './ListFiles.tsx';
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
import CreateDirectoryModal from './CreateDirectoryModal.tsx';
import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx';
import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx';
import CreateShareModal from './CreateShareModal.tsx';
import ManageShareModal from './ManageShareModal.tsx';
interface MainFilesProps {
initialDirectories: Directory[];
initialFiles: DirectoryFile[];
initialPath: 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 isUploading = useSignal<boolean>(false);
const isDeleting = useSignal<boolean>(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 (
<>
<section class='flex flex-row items-center justify-between mb-4'>
<section class='relative inline-block text-left mr-2'>
<section class='flex flex-row items-center justify-start'>
<SearchFiles />
{!fileShareId ? <SearchFiles /> : null}
{isAnyItemChosen
? (
@@ -539,63 +697,67 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
</section>
<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'>
<div>
<button
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'
type='button'
title='Add new file or directory'
id='new-button'
aria-expanded='true'
aria-haspopup='true'
onClick={() => toggleNewOptionsDropdown()}
>
<img
src='/images/add.svg'
alt='Add new file or directory'
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</div>
{!fileShareId
? (
<section class='relative inline-block text-left ml-2'>
<div>
<button
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'
type='button'
title='Add new file or directory'
id='new-button'
aria-expanded='true'
aria-haspopup='true'
onClick={() => toggleNewOptionsDropdown()}
>
<img
src='/images/add.svg'
alt='Add new file or directory'
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</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 ${
!areNewOptionsOpen.value ? 'hidden' : ''
}`}
role='menu'
aria-orientation='vertical'
aria-labelledby='new-button'
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'
<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 ${
!areNewOptionsOpen.value ? 'hidden' : ''
}`}
role='menu'
aria-orientation='vertical'
aria-labelledby='new-button'
tabindex={-1}
>
Upload Files
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile(true)}
type='button'
>
Upload Directory
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()}
type='button'
>
New Directory
</button>
</div>
</div>
</section>
<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
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile(true)}
type='button'
>
Upload Directory
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()}
type='button'
>
New Directory
</button>
</div>
</div>
</section>
)
: null}
</section>
</section>
@@ -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}
/>
<span
@@ -650,33 +815,76 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
</span>
</section>
<section class='flex flex-row items-center justify-start my-12'>
<span class='font-semibold'>WebDav URL:</span>{' '}
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav</code>
</section>
{!fileShareId
? (
<section class='flex flex-row items-center justify-start my-12'>
<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
isOpen={isNewDirectoryModalOpen.value}
onClickSave={onClickSaveDirectory}
onClose={onCloseCreateDirectory}
/>
{!fileShareId
? (
<CreateDirectoryModal
isOpen={isNewDirectoryModalOpen.value}
onClickSave={onClickSaveDirectory}
onClose={onCloseCreateDirectory}
/>
)
: null}
<RenameDirectoryOrFileModal
isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
initialName={renameDirectoryOrFileModal.value?.name || ''}
onClickSave={renameDirectoryOrFileModal.value?.isDirectory ? onClickSaveRenameDirectory : onClickSaveRenameFile}
onClose={onClickCloseRename}
/>
{!fileShareId
? (
<RenameDirectoryOrFileModal
isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
initialName={renameDirectoryOrFileModal.value?.name || ''}
onClickSave={renameDirectoryOrFileModal.value?.isDirectory
? onClickSaveRenameDirectory
: onClickSaveRenameFile}
onClose={onClickCloseRename}
/>
)
: null}
<MoveDirectoryOrFileModal
isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
initialPath={moveDirectoryOrFileModal.value?.path || ''}
name={moveDirectoryOrFileModal.value?.name || ''}
onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile}
onClose={onClickCloseMove}
/>
{!fileShareId
? (
<MoveDirectoryOrFileModal
isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
initialPath={moveDirectoryOrFileModal.value?.path || ''}
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) => (
<li class='mb-1'>
<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`}
target='_blank'
rel='noopener noreferrer'
@@ -136,7 +138,9 @@ export default function SearchFiles({}: SearchFilesProps) {
{matchingFiles.value.map((file) => (
<li class='mb-1'>
<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`}
target='_blank'
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 (
<article class='hover:opacity-70'>
<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'
target='_blank'
rel='noopener noreferrer'
@@ -39,7 +41,9 @@ export default function ListPhotos(
? (
<video class='h-auto max-w-full rounded-md' title={file.file_name}>
<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}`}
/>
</video>
@@ -48,7 +52,9 @@ export default function ListPhotos(
{isImage
? (
<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'
alt={file.file_name}
title={file.file_name}