Files UI complete with search, sharing via symlink.

This commit is contained in:
Bruno Bernardino
2024-04-04 17:55:47 +01:00
parent bd4be8dbec
commit aee720fbc4
16 changed files with 316 additions and 589 deletions

View File

@@ -60,20 +60,23 @@ Just push to the `main` branch.
- [x] Dashboard with URLs and Notes - [x] Dashboard with URLs and Notes
- [x] News - [x] News
- [ ] Files UI - [x] Files UI
- [ ] Notes UI
- [ ] Photos UI
- [ ] Desktop app for selective file sync (WebDav or potentially just `rclone` or `rsync`) - [ ] Desktop app for selective file sync (WebDav or potentially just `rclone` or `rsync`)
- [ ] Mobile app for offline file sync - [ ] Mobile app for offline file sync
- [ ] Add notes support for mobile app - [ ] Add notes support for mobile app
- [ ] Add photos/sync support for mobile client - [ ] Add photos/sync support for mobile client
- [ ] Notes UI
- [ ] Photos UI
- [ ] Address `TODO:`s in code - [ ] Address `TODO:`s in code
- [ ] Basic Contacts UI via CardDav?
- [ ] Basic Calendar UI via CalDav?
- [ ] Basic Tasks UI via CalDav?
## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement? ## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement?
[Check this tag/release for more info and the code where/when that was being done](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). Contacts/CardDav worked and Calendar/CalDav mostly worked as well at that point. [Check this tag/release for more info and the code where/when that was being done](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). Contacts/CardDav worked and Calendar/CalDav mostly worked as well at that point.
My focus is still to get me to replace Nextcloud for me and my family ASAP, but turns out it's not easy to do it all in a single, installable _thing_, so I'm focusing 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 is still to get me to replace Nextcloud for me and my family ASAP, but turns out it's not easy to do it all in a single, installable _thing_, so I'm focusing 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?
[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).
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

@@ -2,7 +2,7 @@ import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
// import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx'; import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx';
interface SearchFilesProps {} interface SearchFilesProps {}
export default function SearchFiles({}: SearchFilesProps) { export default function SearchFiles({}: SearchFilesProps) {
@@ -32,31 +32,30 @@ export default function SearchFiles({}: SearchFilesProps) {
areResultsVisible.value = false; areResultsVisible.value = false;
searchTimeout.value = setTimeout(() => { searchTimeout.value = setTimeout(async () => {
isSearching.value = true; isSearching.value = true;
// TODO: Build this try {
// try { const requestBody: RequestBody = { searchTerm };
// const requestBody: RequestBody = { searchTerm }; const response = await fetch(`/api/files/search`, {
// const response = await fetch(`/api/files/search`, { method: 'POST',
// method: 'POST', body: JSON.stringify(requestBody),
// body: JSON.stringify(requestBody), });
// }); const result = await response.json() as ResponseBody;
// const result = await response.json() as ResponseBody;
// if (!result.success) { if (!result.success) {
// throw new Error('Failed to search files!'); throw new Error('Failed to search files!');
// } }
// matchingDirectories.value = result.matchingDirectories; matchingDirectories.value = [...result.directories];
// matchingFiles.value = result.matchingFiles; matchingFiles.value = [...result.files];
// if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) { if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) {
// areResultsVisible.value = true; areResultsVisible.value = true;
// } }
// } catch (error) { } catch (error) {
// console.error(error); console.error(error);
// } }
isSearching.value = false; isSearching.value = false;
}, 500); }, 500);
@@ -104,9 +103,9 @@ export default function SearchFiles({}: SearchFilesProps) {
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null} {isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
{areResultsVisible.value {areResultsVisible.value
? ( ? (
<section class='relative inline-block text-left ml-2 text-xs'> <section class='relative inline-block text-left ml-2 text-sm'>
<section <section
class={`absolute right-0 z-10 mt-2 w-56 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-80 origin-top-right rounded-md bg-slate-600 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none overflow-y-scroll max-h-[80%] min-h-56`}
role='menu' role='menu'
aria-orientation='vertical' aria-orientation='vertical'
aria-labelledby='view-button' aria-labelledby='view-button'
@@ -118,13 +117,13 @@ export default function SearchFiles({}: SearchFilesProps) {
<li class='mb-1'> <li class='mb-1'>
<a <a
href={`/files?path=${directory.parent_path}${directory.directory_name}`} href={`/files?path=${directory.parent_path}${directory.directory_name}`}
class={`block px-2 py-2 hover:no-underline hover:opacity-60`} 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'
> >
<time <time
datetime={new Date(directory.updated_at).toISOString()} datetime={new Date(directory.updated_at).toISOString()}
class='mr-2 flex-none text-slate-100 block' class='mr-2 flex-none text-slate-100 block text-xs'
> >
{dateFormat.format(new Date(directory.updated_at))} {dateFormat.format(new Date(directory.updated_at))}
</time> </time>
@@ -138,13 +137,13 @@ export default function SearchFiles({}: SearchFilesProps) {
<li class='mb-1'> <li class='mb-1'>
<a <a
href={`/files/open/${file.file_name}?path=${file.parent_path}`} href={`/files/open/${file.file_name}?path=${file.parent_path}`}
class={`block px-2 py-2 hover:no-underline hover:opacity-60`} 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'
> >
<time <time
datetime={new Date(file.updated_at).toISOString()} datetime={new Date(file.updated_at).toISOString()}
class='mr-2 flex-none text-slate-100 block' class='mr-2 flex-none text-slate-100 block text-xs'
> >
{dateFormat.format(new Date(file.updated_at))} {dateFormat.format(new Date(file.updated_at))}
</time> </time>

View File

@@ -1,56 +0,0 @@
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;
--
-- Name: bewcloud_file_shares; Type: TABLE; Schema: public; Owner: postgres
--
CREATE TABLE public.bewcloud_file_shares (
id uuid DEFAULT gen_random_uuid(),
owner_user_id uuid DEFAULT gen_random_uuid(),
owner_parent_path text NOT NULL,
parent_path text NOT NULL,
name text NOT NULL,
type varchar NOT NULL,
user_ids_with_read_access uuid[] NOT NULL,
user_ids_with_write_access uuid[] NOT NULL,
extra jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.bewcloud_file_shares OWNER TO postgres;
CREATE UNIQUE INDEX bewcloud_file_shares_unique_index ON public.bewcloud_file_shares ( owner_user_id, owner_parent_path, name, type );
--
-- Name: bewcloud_file_shares bewcloud_file_shares_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.bewcloud_file_shares
ADD CONSTRAINT bewcloud_file_shares_pkey PRIMARY KEY (id);
--
-- Name: bewcloud_file_shares bewcloud_file_shares_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
ALTER TABLE ONLY public.bewcloud_file_shares
ADD CONSTRAINT bewcloud_file_shares_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.bewcloud_users(id);
--
-- Name: TABLE bewcloud_file_shares; Type: ACL; Schema: public; Owner: postgres
--
GRANT ALL ON TABLE public.bewcloud_file_shares TO postgres;

View File

@@ -15,6 +15,7 @@ import * as $api_files_move_directory from './routes/api/files/move-directory.ts
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_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';
@@ -54,6 +55,7 @@ const manifest = {
'./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/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,

View File

@@ -1,66 +1,24 @@
import { join } from 'std/path/join.ts'; import { join } from 'std/path/join.ts';
import Database, { sql } from '/lib/interfaces/database.ts';
import { getFilesRootPath } from '/lib/config.ts'; import { getFilesRootPath } from '/lib/config.ts';
import { Directory, DirectoryFile, FileShare, FileShareLink } from '/lib/types.ts'; import { Directory, DirectoryFile } 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';
const db = new Database();
export async function getDirectories(userId: string, path: string): Promise<Directory[]> { export async function getDirectories(userId: string, path: string): Promise<Directory[]> {
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
const directoryShares = await db.query<FileShare>(
sql`SELECT * FROM "bewcloud_file_shares"
WHERE "parent_path" = $2
AND "type" = 'directory'
AND (
"owner_user_id" = $1
OR ANY("user_ids_with_read_access") = $1
OR ANY("user_ids_with_write_access") = $1
)`,
[
userId,
path,
],
);
const directories: Directory[] = []; const directories: Directory[] = [];
const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory); const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory || entry.isSymlink);
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));
const directory: Directory = { const directory: Directory = {
owner_user_id: userId, user_id: userId,
parent_path: path, parent_path: path,
directory_name: entry.name, directory_name: entry.name,
has_write_access: true, has_write_access: true,
file_share: directoryShares.find((share) =>
share.owner_user_id === userId && share.parent_path === path && share.name === entry.name
),
size_in_bytes: stat.size,
updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(),
};
directories.push(directory);
}
// Add directoryShares that aren't owned by this user
const foreignDirectoryShares = directoryShares.filter((directoryShare) => directoryShare.owner_user_id !== userId);
for (const share of foreignDirectoryShares) {
const stat = await Deno.stat(join(getFilesRootPath(), share.owner_user_id, path, share.name));
const hasWriteAccess = share.user_ids_with_write_access.includes(userId);
const directory: Directory = {
owner_user_id: share.owner_user_id,
parent_path: path,
directory_name: share.name,
has_write_access: hasWriteAccess,
file_share: share,
size_in_bytes: stat.size, size_in_bytes: stat.size,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
@@ -77,21 +35,6 @@ export async function getDirectories(userId: string, path: string): Promise<Dire
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> { export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
const fileShares = await db.query<FileShare>(
sql`SELECT * FROM "bewcloud_file_shares"
WHERE "parent_path" = $2
AND "type" = 'file'
AND (
"owner_user_id" = $1
OR ANY("user_ids_with_read_access") = $1
OR ANY("user_ids_with_write_access") = $1
)`,
[
userId,
path,
],
);
const files: DirectoryFile[] = []; const files: DirectoryFile[] = [];
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile); const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
@@ -100,13 +43,10 @@ export async function getFiles(userId: string, path: string): Promise<DirectoryF
const stat = await Deno.stat(join(rootPath, entry.name)); const stat = await Deno.stat(join(rootPath, entry.name));
const file: DirectoryFile = { const file: DirectoryFile = {
owner_user_id: userId, user_id: userId,
parent_path: path, parent_path: path,
file_name: entry.name, file_name: entry.name,
has_write_access: true, has_write_access: true,
file_share: fileShares.find((share) =>
share.owner_user_id === userId && share.parent_path === path && share.name === entry.name
),
size_in_bytes: stat.size, size_in_bytes: stat.size,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
@@ -115,29 +55,6 @@ export async function getFiles(userId: string, path: string): Promise<DirectoryF
files.push(file); files.push(file);
} }
// Add fileShares that aren't owned by this user
const foreignFileShares = fileShares.filter((fileShare) => fileShare.owner_user_id !== userId);
for (const share of foreignFileShares) {
const stat = await Deno.stat(join(getFilesRootPath(), share.owner_user_id, path, share.name));
const hasWriteAccess = share.user_ids_with_write_access.includes(userId);
const file: DirectoryFile = {
owner_user_id: share.owner_user_id,
parent_path: path,
file_name: share.name,
has_write_access: hasWriteAccess,
file_share: share,
size_in_bytes: stat.size,
updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(),
};
files.push(file);
}
// TODO: Check fileshare directories and list files from there too
files.sort(sortFilesByName); files.sort(sortFilesByName);
return files; return files;
@@ -193,23 +110,6 @@ export async function renameDirectoryOrFile(
try { try {
await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName)); await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName));
// Update any matching file shares
await db.query<FileShare>(
sql`UPDATE "bewcloud_file_shares" SET
"parent_path" = $4,
"name" = $5
WHERE "parent_path" = $2
AND "name" = $3
AND "owner_user_id" = $1`,
[
userId,
oldPath,
oldName,
newPath,
newName,
],
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return false; return false;
@@ -227,19 +127,6 @@ export async function deleteDirectoryOrFile(userId: string, path: string, name:
} else { } else {
const trashPath = join(getFilesRootPath(), userId, TRASH_PATH); const trashPath = join(getFilesRootPath(), userId, TRASH_PATH);
await Deno.rename(join(rootPath, name), join(trashPath, name)); await Deno.rename(join(rootPath, name), join(trashPath, name));
// Delete any matching file shares
await db.query<FileShare>(
sql`DELETE FROM "bewcloud_file_shares"
WHERE "parent_path" = $2
AND "name" = $3
AND "owner_user_id" = $1`,
[
userId,
path,
name,
],
);
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -290,6 +177,8 @@ export async function getFile(
contentType = 'image/jpeg'; contentType = 'image/jpeg';
} else if (extension === 'png') { } else if (extension === 'png') {
contentType = 'image/png'; contentType = 'image/png';
} else if (extension === 'svg') {
contentType = 'image/svg+xml';
} else if (extension === 'pdf') { } else if (extension === 'pdf') {
contentType = 'application/pdf'; contentType = 'application/pdf';
} else if (extension === 'txt' || extension === 'md') { } else if (extension === 'txt' || extension === 'md') {
@@ -309,219 +198,236 @@ export async function getFile(
} }
} }
export async function createFileShare( export async function searchFilesAndDirectories(
userId: string, userId: string,
path: string, searchTerm: string,
name: string, ): Promise<{ success: boolean; directories: Directory[]; files: DirectoryFile[] }> {
type: 'directory' | 'file', const directoryNamesResult = await searchDirectoryNames(userId, searchTerm);
userIdsWithReadAccess: string[], const fileNamesResult = await searchFileNames(userId, searchTerm);
userIdsWithWriteAccess: string[], const fileContentsResult = await searchFileContents(userId, searchTerm);
readShareLinks: FileShareLink[],
writeShareLinks: FileShareLink[], const success = directoryNamesResult.success && fileNamesResult.success && fileContentsResult.success;
): Promise<FileShare> {
const extra: FileShare['extra'] = { const directories = [...directoryNamesResult.directories];
read_share_links: readShareLinks, directories.sort(sortDirectoriesByName);
write_share_links: writeShareLinks,
const files = [...fileNamesResult.files, ...fileContentsResult.files];
files.sort(sortFilesByName);
return {
success,
directories,
files,
};
}
async function searchDirectoryNames(
userId: string,
searchTerm: string,
): Promise<{ success: boolean; directories: Directory[] }> {
const rootPath = `${getFilesRootPath()}/${userId}/`;
const directories: Directory[] = [];
try {
const controller = new AbortController();
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
const command = new Deno.Command(`find`, {
args: [
`.`, // proper cwd is sent below
`-type`,
`d,l`, // directories and symbolic links
`-iname`,
`*${searchTerm}*`,
],
cwd: rootPath,
signal: controller.signal,
});
const { code, stdout, stderr } = await command.output();
if (commandTimeout) {
clearTimeout(commandTimeout);
}
if (code !== 0) {
if (stderr) {
throw new Error(new TextDecoder().decode(stderr));
}
throw new Error(`Unknown error running "find"`);
}
const output = new TextDecoder().decode(stdout);
const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean);
for (const relativeDirectoryPath of matchingDirectories) {
const stat = await Deno.stat(join(rootPath, relativeDirectoryPath));
let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
const directoryName = relativeDirectoryPath.split('/').pop()!;
if (parentPath === '//') {
parentPath = '/';
}
const directory: Directory = {
user_id: userId,
parent_path: parentPath,
directory_name: directoryName,
has_write_access: true,
size_in_bytes: stat.size,
updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(),
}; };
const newFileShare = (await db.query<FileShare>( directories.push(directory);
sql`INSERT INTO "bewcloud_file_shares" ( }
"owner_user_id",
"owner_parent_path",
"parent_path",
"name",
"type",
"user_ids_with_read_access",
"user_ids_with_write_access",
"extra"
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
userId,
path,
'/',
name,
type,
userIdsWithReadAccess,
userIdsWithWriteAccess,
JSON.stringify(extra),
],
))[0];
return newFileShare; return { success: true, directories };
}
export async function updateFileShare(fileShare: FileShare) {
await db.query(
sql`UPDATE "bewcloud_file_shares" SET
"owner_parent_path" = $2,
"parent_path" = $3,
"name" = $4,
"user_ids_with_read_access" = $5,
"user_ids_with_write_access" = $6,
"extra" = $7
WHERE "id" = $1`,
[
fileShare.id,
fileShare.owner_parent_path,
fileShare.parent_path,
fileShare.name,
fileShare.user_ids_with_read_access,
fileShare.user_ids_with_write_access,
JSON.stringify(fileShare.extra),
],
);
}
export async function getDirectoryAccess(
userId: string,
parentPath: string,
name?: string,
): Promise<{ hasReadAccess: boolean; hasWriteAccess: boolean; ownerUserId: string; ownerParentPath: string }> {
const rootPath = join(getFilesRootPath(), userId, parentPath, name || '');
// If it exists in the correct filesystem path, it's the user's
try {
await Deno.stat(rootPath);
return { hasReadAccess: true, hasWriteAccess: true, ownerUserId: userId, ownerParentPath: parentPath };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
// Otherwise check if it's been shared with them return { success: false, directories };
const parentPaths: string[] = [];
let nextParentPath: string | null = rootPath;
while (nextParentPath !== null) {
parentPaths.push(nextParentPath);
nextParentPath = `/${nextParentPath.split('/').filter(Boolean).slice(0, -1).join('/')}`;
if (nextParentPath === '/') {
parentPaths.push(nextParentPath);
nextParentPath = null;
}
}
const fileShare = (await db.query<FileShare>(
sql`SELECT * FROM "bewcloud_file_shares"
WHERE "parent_path" = ANY($2)
AND "name" = $3
AND "type" = 'directory'
AND (
ANY("user_ids_with_read_access") = $1
OR ANY("user_ids_with_write_access") = $1
)
ORDER BY "parent_path" ASC
LIMIT 1`,
[
userId,
parentPaths,
name,
],
))[0];
if (fileShare) {
return {
hasReadAccess: fileShare.user_ids_with_read_access.includes(userId) ||
fileShare.user_ids_with_write_access.includes(userId),
hasWriteAccess: fileShare.user_ids_with_write_access.includes(userId),
ownerUserId: fileShare.owner_user_id,
ownerParentPath: fileShare.owner_parent_path,
};
}
return { hasReadAccess: false, hasWriteAccess: false, ownerUserId: userId, ownerParentPath: parentPath };
} }
export async function getFileAccess( async function searchFileNames(
userId: string, userId: string,
parentPath: string, searchTerm: string,
name: string, ): Promise<{ success: boolean; files: DirectoryFile[] }> {
): Promise<{ hasReadAccess: boolean; hasWriteAccess: boolean; ownerUserId: string; ownerParentPath: string }> { const rootPath = `${getFilesRootPath()}/${userId}/`;
const rootPath = join(getFilesRootPath(), userId, parentPath, name);
const files: DirectoryFile[] = [];
// If it exists in the correct filesystem path, it's the user's
try { try {
await Deno.stat(rootPath); const controller = new AbortController();
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
return { hasReadAccess: true, hasWriteAccess: true, ownerUserId: userId, ownerParentPath: parentPath }; const command = new Deno.Command(`find`, {
args: [
`.`, // proper cwd is sent below
`-type`,
`f`,
`-iname`,
`*${searchTerm}*`,
],
cwd: rootPath,
signal: controller.signal,
});
const { code, stdout, stderr } = await command.output();
if (commandTimeout) {
clearTimeout(commandTimeout);
}
if (code !== 0) {
if (stderr) {
throw new Error(new TextDecoder().decode(stderr));
}
throw new Error(`Unknown error running "find"`);
}
const output = new TextDecoder().decode(stdout);
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
for (const relativeFilePath of matchingFiles) {
const stat = await Deno.stat(join(rootPath, relativeFilePath));
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
const fileName = relativeFilePath.split('/').pop()!;
if (parentPath === '//') {
parentPath = '/';
}
const file: DirectoryFile = {
user_id: userId,
parent_path: parentPath,
file_name: fileName,
has_write_access: true,
size_in_bytes: stat.size,
updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(),
};
files.push(file);
}
return { success: true, files };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
// Otherwise check if it's been shared with them return { success: false, files };
let fileShare = (await db.query<FileShare>( }
sql`SELECT * FROM "bewcloud_file_shares"
WHERE "parent_path" = $2 async function searchFileContents(
AND "name" = $3 userId: string,
AND "type" = 'file' searchTerm: string,
AND ( ): Promise<{ success: boolean; files: DirectoryFile[] }> {
ANY("user_ids_with_read_access") = $1 const rootPath = `${getFilesRootPath()}/${userId}/`;
OR ANY("user_ids_with_write_access") = $1
) const files: DirectoryFile[] = [];
ORDER BY "parent_path" ASC
LIMIT 1`, try {
[ const controller = new AbortController();
userId, const commandTimeout = setTimeout(() => controller.abort(), 10_000);
parentPath,
name, const command = new Deno.Command(`grep`, {
], args: [
))[0]; `-rHisl`,
`${searchTerm}`,
if (fileShare) { `.`, // proper cwd is sent below
return { ],
hasReadAccess: fileShare.user_ids_with_read_access.includes(userId) || cwd: rootPath,
fileShare.user_ids_with_write_access.includes(userId), signal: controller.signal,
hasWriteAccess: fileShare.user_ids_with_write_access.includes(userId), });
ownerUserId: fileShare.owner_user_id,
ownerParentPath: fileShare.owner_parent_path, const { code, stdout, stderr } = await command.output();
};
} if (commandTimeout) {
clearTimeout(commandTimeout);
// Otherwise check if it's a parent directory has been shared with them, which would also give them access }
const parentPaths: string[] = [];
let nextParentPath: string | null = rootPath; if (code !== 0) {
if (stderr) {
while (nextParentPath !== null) { throw new Error(new TextDecoder().decode(stderr));
parentPaths.push(nextParentPath); }
nextParentPath = `/${nextParentPath.split('/').filter(Boolean).slice(0, -1).join('/')}`; throw new Error(`Unknown error running "grep"`);
}
if (nextParentPath === '/') {
parentPaths.push(nextParentPath); const output = new TextDecoder().decode(stdout);
nextParentPath = null; const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
}
} for (const relativeFilePath of matchingFiles) {
const stat = await Deno.stat(join(rootPath, relativeFilePath));
fileShare = (await db.query<FileShare>( let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
sql`SELECT * FROM "bewcloud_file_shares" const fileName = relativeFilePath.split('/').pop()!;
WHERE "parent_path" = ANY($2)
AND "name" = $3 if (parentPath === '//') {
AND "type" = 'directory' parentPath = '/';
AND ( }
ANY("user_ids_with_read_access") = $1
OR ANY("user_ids_with_write_access") = $1 const file: DirectoryFile = {
) user_id: userId,
ORDER BY "parent_path" ASC parent_path: parentPath,
LIMIT 1`, file_name: fileName,
[ has_write_access: true,
userId, size_in_bytes: stat.size,
parentPaths, updated_at: stat.mtime || new Date(),
name, created_at: stat.birthtime || new Date(),
], };
))[0];
files.push(file);
if (fileShare) { }
return {
hasReadAccess: fileShare.user_ids_with_read_access.includes(userId) || return { success: true, files };
fileShare.user_ids_with_write_access.includes(userId), } catch (error) {
hasWriteAccess: fileShare.user_ids_with_write_access.includes(userId), console.error(error);
ownerUserId: fileShare.owner_user_id, }
ownerParentPath: fileShare.owner_parent_path,
}; return { success: false, files };
}
return { hasReadAccess: false, hasWriteAccess: false, ownerUserId: userId, ownerParentPath: parentPath };
} }

View File

@@ -85,44 +85,21 @@ export interface NewsFeedArticle {
created_at: Date; created_at: Date;
} }
export interface FileShareLink {
url: string;
hashed_password: string;
}
export interface FileShare {
id: string;
owner_user_id: string;
owner_parent_path: string;
parent_path: string;
name: string;
type: 'directory' | 'file';
user_ids_with_read_access: string[];
user_ids_with_write_access: string[];
extra: {
read_share_links: FileShareLink[];
write_share_links: FileShareLink[];
};
created_at: Date;
}
export interface Directory { export interface Directory {
owner_user_id: string; user_id: string;
parent_path: string; parent_path: string;
directory_name: string; directory_name: string;
has_write_access: boolean; has_write_access: boolean;
file_share?: FileShare;
size_in_bytes: number; size_in_bytes: number;
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;
} }
export interface DirectoryFile { export interface DirectoryFile {
owner_user_id: string; user_id: string;
parent_path: string; parent_path: string;
file_name: string; file_name: string;
has_write_access: boolean; has_write_access: boolean;
file_share?: FileShare;
size_in_bytes: number; size_in_bytes: number;
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { Directory, FreshContextState } from '/lib/types.ts'; import { Directory, FreshContextState } from '/lib/types.ts';
import { createDirectory, getDirectories, getDirectoryAccess } from '/lib/data/files.ts'; import { createDirectory, getDirectories } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -30,22 +30,12 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
const { hasWriteAccess, ownerUserId, ownerParentPath } = await getDirectoryAccess( const createdDirectory = await createDirectory(
context.state.user.id, context.state.user.id,
requestBody.parentPath, requestBody.parentPath,
requestBody.name.trim(), requestBody.name.trim(),
); );
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
const createdDirectory = await createDirectory(
ownerUserId,
ownerParentPath,
requestBody.name.trim(),
);
const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath);
const responseBody: ResponseBody = { success: createdDirectory, newDirectories }; const responseBody: ResponseBody = { success: createdDirectory, newDirectories };

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { Directory, FreshContextState } from '/lib/types.ts'; import { Directory, FreshContextState } from '/lib/types.ts';
import { deleteDirectoryOrFile, getDirectories, getDirectoryAccess } from '/lib/data/files.ts'; import { deleteDirectoryOrFile, getDirectories } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -30,22 +30,12 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
const { hasWriteAccess, ownerUserId, ownerParentPath } = await getDirectoryAccess( const deletedDirectory = await deleteDirectoryOrFile(
context.state.user.id, context.state.user.id,
requestBody.parentPath, requestBody.parentPath,
requestBody.name.trim(), requestBody.name.trim(),
); );
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
const deletedDirectory = await deleteDirectoryOrFile(
ownerUserId,
ownerParentPath,
requestBody.name.trim(),
);
const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath);
const responseBody: ResponseBody = { success: deletedDirectory, newDirectories }; const responseBody: ResponseBody = { success: deletedDirectory, newDirectories };

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { DirectoryFile, FreshContextState } from '/lib/types.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts';
import { deleteDirectoryOrFile, getDirectoryAccess, getFileAccess, getFiles } from '/lib/data/files.ts'; import { deleteDirectoryOrFile, getFiles } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -30,30 +30,12 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
let { hasWriteAccess, ownerUserId, ownerParentPath } = await getFileAccess( const deletedFile = await deleteDirectoryOrFile(
context.state.user.id, context.state.user.id,
requestBody.parentPath, requestBody.parentPath,
requestBody.name.trim(), requestBody.name.trim(),
); );
if (!hasWriteAccess) {
const directoryAccessResult = await getDirectoryAccess(context.state.user.id, requestBody.parentPath);
hasWriteAccess = directoryAccessResult.hasWriteAccess;
ownerUserId = directoryAccessResult.ownerUserId;
ownerParentPath = directoryAccessResult.ownerParentPath;
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
}
const deletedFile = await deleteDirectoryOrFile(
ownerUserId,
ownerParentPath,
requestBody.name.trim(),
);
const newFiles = await getFiles(context.state.user.id, requestBody.parentPath); const newFiles = await getFiles(context.state.user.id, requestBody.parentPath);
const responseBody: ResponseBody = { success: deletedFile, newFiles }; const responseBody: ResponseBody = { success: deletedFile, newFiles };

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { Directory, FreshContextState } from '/lib/types.ts'; import { Directory, FreshContextState } from '/lib/types.ts';
import { getDirectories, getDirectoryAccess, renameDirectoryOrFile } from '/lib/data/files.ts'; import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -33,26 +33,10 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
const { hasWriteAccess: hasOldWriteAccess, ownerUserId, ownerParentPath: oldOwnerParentPath } =
await getDirectoryAccess(context.state.user.id, requestBody.oldParentPath, requestBody.name.trim());
if (!hasOldWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
const { hasWriteAccess: hasNewWriteAccess, ownerParentPath: newOwnerParentPath } = await getDirectoryAccess(
context.state.user.id,
requestBody.newParentPath,
);
if (!hasNewWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
const movedDirectory = await renameDirectoryOrFile( const movedDirectory = await renameDirectoryOrFile(
ownerUserId, context.state.user.id,
oldOwnerParentPath, requestBody.oldParentPath,
newOwnerParentPath, requestBody.newParentPath,
requestBody.name.trim(), requestBody.name.trim(),
requestBody.name.trim(), requestBody.name.trim(),
); );

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { DirectoryFile, FreshContextState } from '/lib/types.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts';
import { getDirectoryAccess, getFileAccess, getFiles, renameDirectoryOrFile } from '/lib/data/files.ts'; import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -33,35 +33,10 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
let { hasWriteAccess: hasOldWriteAccess, ownerUserId, ownerParentPath: oldOwnerParentPath } = await getFileAccess( const movedFile = await renameDirectoryOrFile(
context.state.user.id, context.state.user.id,
requestBody.oldParentPath, requestBody.oldParentPath,
requestBody.name.trim(),
);
if (!hasOldWriteAccess) {
const directoryAccessResult = await getDirectoryAccess(context.state.user.id, requestBody.oldParentPath);
hasOldWriteAccess = directoryAccessResult.hasWriteAccess;
ownerUserId = directoryAccessResult.ownerUserId;
oldOwnerParentPath = directoryAccessResult.ownerParentPath;
return new Response('Forbidden', { status: 403 });
}
const { hasWriteAccess: hasNewWriteAccess, ownerParentPath: newOwnerParentPath } = await getDirectoryAccess(
context.state.user.id,
requestBody.newParentPath, requestBody.newParentPath,
);
if (!hasNewWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
const movedFile = await renameDirectoryOrFile(
ownerUserId,
oldOwnerParentPath,
newOwnerParentPath,
requestBody.name.trim(), requestBody.name.trim(),
requestBody.name.trim(), requestBody.name.trim(),
); );

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { Directory, FreshContextState } from '/lib/types.ts'; import { Directory, FreshContextState } from '/lib/types.ts';
import { getDirectories, getDirectoryAccess, renameDirectoryOrFile } from '/lib/data/files.ts'; import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -32,20 +32,10 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
const { hasWriteAccess, ownerUserId, ownerParentPath } = await getDirectoryAccess( const movedDirectory = await renameDirectoryOrFile(
context.state.user.id, context.state.user.id,
requestBody.parentPath, requestBody.parentPath,
requestBody.oldName.trim(), requestBody.parentPath,
);
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
const movedDirectory = await renameDirectoryOrFile(
ownerUserId,
ownerParentPath,
ownerParentPath,
requestBody.oldName.trim(), requestBody.oldName.trim(),
requestBody.newName.trim(), requestBody.newName.trim(),
); );

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { DirectoryFile, FreshContextState } from '/lib/types.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts';
import { getDirectoryAccess, getFileAccess, getFiles, renameDirectoryOrFile } from '/lib/data/files.ts'; import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -32,28 +32,10 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
let { hasWriteAccess, ownerUserId, ownerParentPath } = await getFileAccess( const movedFile = await renameDirectoryOrFile(
context.state.user.id, context.state.user.id,
requestBody.parentPath, requestBody.parentPath,
requestBody.oldName.trim(), requestBody.parentPath,
);
if (!hasWriteAccess) {
const directoryAccessResult = await getDirectoryAccess(context.state.user.id, requestBody.parentPath);
hasWriteAccess = directoryAccessResult.hasWriteAccess;
ownerUserId = directoryAccessResult.ownerUserId;
ownerParentPath = directoryAccessResult.ownerParentPath;
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
}
const movedFile = await renameDirectoryOrFile(
ownerUserId,
ownerParentPath,
ownerParentPath,
requestBody.oldName.trim(), requestBody.oldName.trim(),
requestBody.newName.trim(), requestBody.newName.trim(),
); );

View File

@@ -0,0 +1,39 @@
import { Handlers } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import { searchFilesAndDirectories } from '/lib/data/files.ts';
interface Data {}
export interface RequestBody {
searchTerm: string;
}
export interface ResponseBody {
success: boolean;
directories: Directory[];
files: DirectoryFile[];
}
export const handler: Handlers<Data, FreshContextState> = {
async POST(request, context) {
if (!context.state.user) {
return new Response('Unauthorized', { status: 401 });
}
const requestBody = await request.clone().json() as RequestBody;
if (!requestBody.searchTerm?.trim()) {
return new Response('Bad Request', { status: 400 });
}
const result = await searchFilesAndDirectories(
context.state.user.id,
requestBody.searchTerm.trim(),
);
const responseBody: ResponseBody = { ...result };
return new Response(JSON.stringify(responseBody));
},
};

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { DirectoryFile, FreshContextState } from '/lib/types.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts';
import { createFile, getDirectoryAccess, getFileAccess, getFiles } from '/lib/data/files.ts'; import { createFile, getFiles } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -29,25 +29,7 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Bad Request', { status: 400 }); return new Response('Bad Request', { status: 400 });
} }
let { hasWriteAccess, ownerUserId, ownerParentPath } = await getFileAccess( const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), await contents.arrayBuffer());
context.state.user.id,
parentPath,
name.trim(),
);
if (!hasWriteAccess) {
const directoryAccessResult = await getDirectoryAccess(context.state.user.id, parentPath);
hasWriteAccess = directoryAccessResult.hasWriteAccess;
ownerUserId = directoryAccessResult.ownerUserId;
ownerParentPath = directoryAccessResult.ownerParentPath;
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
}
const createdFile = await createFile(ownerUserId, ownerParentPath, name.trim(), await contents.arrayBuffer());
const newFiles = await getFiles(context.state.user.id, parentPath); const newFiles = await getFiles(context.state.user.id, parentPath);

View File

@@ -1,7 +1,7 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts'; import { FreshContextState } from '/lib/types.ts';
import { getDirectoryAccess, getFile, getFileAccess } from '/lib/data/files.ts'; import { getFile } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -31,25 +31,7 @@ export const handler: Handlers<Data, FreshContextState> = {
currentPath = `${currentPath}/`; currentPath = `${currentPath}/`;
} }
let { hasWriteAccess, ownerUserId, ownerParentPath } = await getFileAccess( const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName));
context.state.user.id,
currentPath,
decodeURIComponent(fileName),
);
if (!hasWriteAccess) {
const directoryAccessResult = await getDirectoryAccess(context.state.user.id, currentPath);
hasWriteAccess = directoryAccessResult.hasWriteAccess;
ownerUserId = directoryAccessResult.ownerUserId;
ownerParentPath = directoryAccessResult.ownerParentPath;
if (!hasWriteAccess) {
return new Response('Forbidden', { status: 403 });
}
}
const fileResult = await getFile(ownerUserId, ownerParentPath, decodeURIComponent(fileName));
if (!fileResult.success) { if (!fileResult.success) {
return new Response('Not Found', { status: 404 }); return new Response('Not Found', { status: 404 });