diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..f06d204 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,217 @@ +# bewCloud - FAQ (Frequently Asked Questions) + +## How does Contacts/CardDav and Calendar/CalDav work? + +CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and the "Calendar" client for CalDav is available since [v2.5.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.5.0). [Check this tag/release for custom-made server code where it was all mostly working, except for many edge cases, if you're interested](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). + +In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s //collections/collection-root// //collections/collection-root//`). + +> [!NOTE] +> If you're running radicale with docker, the symlink needs to point to the container's directory, usually starting with `/data` if you didn't change the `radicale-config/config`, otherwise the container will fail to load the linked directory. + +## How does private file sharing work? + +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, if you're interested](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. + +> [!NOTE] +> If you're running the app with docker, the symlink needs to point to the container's directory, usually starting with `/app` if you didn't change the `Dockerfile`, otherwise the container will fail to load the linked directory. + +## How can I use .env for configuration? + +During [v1](https://github.com/bewcloud/bewcloud/releases/tag/v1.0.0), bewCloud was entirely configured with a `.env` file, but since [v2](https://github.com/bewcloud/bewcloud/releases/tag/v2.0.0) it was swapped to being used exclusively for "secrets", and having a more robust `bewcloud.config.ts` file for configuration. While it's unlikely `.env`-only configuration will be supported again in the future, the advantage of a `bewcloud.config.ts` file is that it's more dynamic and powerful, which means you can "hack" your way into using a `.env` file for configuration, like how it was suggested [in this comment](https://github.com/bewcloud/bewcloud/issues/90#issuecomment-3450344972). It's copied below for reference, and it's a bit outdated, but should serve as a good starting point, and you can make a PR to update it: + +> [!NOTE] +> This is not recommended and should only be done if you know what you're doing. + +
+ +bewcloud.config.ts + + +```ts +import { Config, OptionalApp, PartialDeep } from './lib/types.ts'; + +// Check the Config type for all the possible options and instructions. +function requireValue(value: T | undefined, key: string): T { + if (value === undefined || value === '') { + throw new Error(`Environment variable ${key} is required but not set`); + } + return value; +} + +function getEnvString(key: string, defaultValue: string): string { + return Deno.env.get(key) ?? defaultValue; +} + +function getEnvBoolean(key: string, defaultValue: boolean): boolean { + const value = Deno.env.get(key); + if (value === undefined) { + return defaultValue; + } + return value.toLowerCase() === 'true' || value === '1'; +} + +function getEnvNumber(key: string, defaultValue: number): number { + const value = Deno.env.get(key); + if (value === undefined) { + return defaultValue; + } + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + +function getEnvStringArray(key: string, defaultValue: string[]): string[] { + const value = Deno.env.get(key); + if (value === undefined) { + return defaultValue; + } + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} +const config: PartialDeep = { + auth: { + baseUrl: getEnvString('BEWCLOUD_AUTH_BASE_URL', 'http://localhost:8000'), + allowSignups: getEnvBoolean('BEWCLOUD_AUTH_ALLOW_SIGNUPS', false), + enableEmailVerification: getEnvBoolean( + 'BEWCLOUD_AUTH_ENABLE_EMAIL_VERIFICATION', + false, + ), + enableForeverSignup: getEnvBoolean( + 'BEWCLOUD_AUTH_ENABLE_FOREVER_SIGNUP', + true, + ), + enableMultiFactor: getEnvBoolean( + 'BEWCLOUD_AUTH_ENABLE_MULTI_FACTOR', + false, + ), + allowedCookieDomains: getEnvStringArray( + 'BEWCLOUD_AUTH_ALLOWED_COOKIE_DOMAINS', + [], + ), + skipCookieDomainSecurity: getEnvBoolean( + 'BEWCLOUD_AUTH_SKIP_COOKIE_DOMAIN_SECURITY', + false, + ), + enableSingleSignOn: getEnvBoolean( + 'BEWCLOUD_AUTH_ENABLE_SINGLE_SIGN_ON', + false, + ), + singleSignOnUrl: getEnvString('BEWCLOUD_AUTH_SINGLE_SIGN_ON_URL', ''), + singleSignOnEmailAttribute: getEnvString( + 'BEWCLOUD_AUTH_SINGLE_SIGN_ON_EMAIL_ATTRIBUTE', + 'email', + ), + singleSignOnScopes: getEnvStringArray( + 'BEWCLOUD_AUTH_SINGLE_SIGN_ON_SCOPES', + ['openid', 'email'], + ), + }, + files: { + rootPath: getEnvString('BEWCLOUD_FILES_ROOT_PATH', 'data-files'), + allowPublicSharing: getEnvBoolean( + 'BEWCLOUD_FILES_ALLOW_PUBLIC_SHARING', + false, + ), + }, + core: { + enabledApps: getEnvStringArray('BEWCLOUD_CORE_ENABLED_APPS', [ + 'dashboard', + 'files', + 'news', + 'notes', + 'photos', + 'expenses', + ]) as OptionalApp[], + }, + visuals: { + title: getEnvString('BEWCLOUD_VISUALS_TITLE', ''), + description: getEnvString('BEWCLOUD_VISUALS_DESCRIPTION', ''), + helpEmail: getEnvString('BEWCLOUD_VISUALS_HELP_EMAIL', 'help@bewcloud.com'), + }, + email: { + from: getEnvString('BEWCLOUD_EMAIL_FROM', 'help@bewcloud.com'), + host: getEnvString('BEWCLOUD_EMAIL_HOST', 'localhost'), + port: getEnvNumber('BEWCLOUD_EMAIL_PORT', 465), + }, + contacts: { + enableCardDavServer: getEnvBoolean( + 'BEWCLOUD_CONTACTS_ENABLE_CARDDAV_SERVER', + true, + ), + cardDavUrl: getEnvString( + 'BEWCLOUD_CONTACTS_CARDDAV_URL', + 'http://127.0.0.1:5232', + ), + }, + calendar: { + enableCalDavServer: getEnvBoolean( + 'BEWCLOUD_CALENDAR_ENABLE_CALDAV_SERVER', + true, + ), + calDavUrl: getEnvString( + 'BEWCLOUD_CALENDAR_CALDAV_URL', + 'http://127.0.0.1:5232', + ), + }, +}; + +export default config; +``` + +
+ +Append the following to your `.env` file + +
+ .env + +```env +# Auth Configuration +BEWCLOUD_AUTH_BASE_URL=http://localhost:8000 +BEWCLOUD_AUTH_ALLOW_SIGNUPS=false +BEWCLOUD_AUTH_ENABLE_EMAIL_VERIFICATION=false +BEWCLOUD_AUTH_ENABLE_FOREVER_SIGNUP=true +BEWCLOUD_AUTH_ENABLE_MULTI_FACTOR=false +# Comma-separated list of allowed cookie domains +BEWCLOUD_AUTH_ALLOWED_COOKIE_DOMAINS= +BEWCLOUD_AUTH_SKIP_COOKIE_DOMAIN_SECURITY=false + +# Single Sign-On Configuration +BEWCLOUD_AUTH_ENABLE_SINGLE_SIGN_ON=false +BEWCLOUD_AUTH_SINGLE_SIGN_ON_URL= +BEWCLOUD_AUTH_SINGLE_SIGN_ON_EMAIL_ATTRIBUTE=email +# Comma-separated list of scopes +BEWCLOUD_AUTH_SINGLE_SIGN_ON_SCOPES=openid,email + +# Files Configuration +BEWCLOUD_FILES_ROOT_PATH=data-files +BEWCLOUD_FILES_ALLOW_PUBLIC_SHARING=false + +# Core Configuration +# Comma-separated list of enabled apps (dashboard and files cannot be disabled) +BEWCLOUD_CORE_ENABLED_APPS=news,notes,photos,expenses,contacts,calendar + +# Visuals Configuration +BEWCLOUD_VISUALS_TITLE= +BEWCLOUD_VISUALS_DESCRIPTION= +BEWCLOUD_VISUALS_HELP_EMAIL=help@bewcloud.com + +# Email/SMTP Configuration +BEWCLOUD_EMAIL_FROM=help@bewcloud.com +BEWCLOUD_EMAIL_HOST=localhost +BEWCLOUD_EMAIL_PORT=465 + +# Contacts Configuration +BEWCLOUD_CONTACTS_ENABLE_CARDDAV_SERVER=true +BEWCLOUD_CONTACTS_CARDDAV_URL=http://127.0.0.1:5232 + +# Calendar Configuration +BEWCLOUD_CALENDAR_ENABLE_CALDAV_SERVER=true +BEWCLOUD_CALENDAR_CALDAV_URL=http://127.0.0.1:5232 +``` + +
diff --git a/README.md b/README.md index 5e76166..be34959 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github [![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, to run on your own machine using Docker, start with these commands: +Or, to run on your own machine, start with these commands: ```sh mkdir data-files data-radicale radicale-config # local directories for storing user-uploaded files, radicale data, and radicale config (these last two are necessary only if you're using CalDav/CardDav/Contacts) @@ -39,9 +39,9 @@ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializ > > `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. -See the [Community Links](#community-links) section for alternative ways of running bewCloud yourself; please be aware these are not officially endorsed. +If you're interested in building/contributing (or just running the app locally), check the [Development section below](#development). -If you're interested in building/contributing, check the [Development section below](#development). +See the [Community Links](#community-links) section for alternative ways of running bewCloud yourself. > [!IMPORTANT] > Even with signups disabled (`config.auth.allowSignups=false`), the first signup will work and become an admin. @@ -73,18 +73,18 @@ These are the amazing entities or individuals who are sponsoring this project fo docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally make migrate-db # runs any missing database migrations make start # runs the app -make format # formats the code -make test # runs tests +make format # (optional) formats the code (if you're interested in contributing) +make test # (optional) runs tests (if you're interested in contributing) ``` -### Other less-used commands +### Other less-used commands (mostly for development) ```sh make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";` make build # generates all static files for production deploy ``` -## Structure +## File/Directory Structure - Routes are defined at `routes/`. - Static files are defined at `static/`. @@ -98,23 +98,9 @@ make build # generates all static files for production deploy Just push to the `main` branch. -## How does Contacts/CardDav and Calendar/CalDav work? +## FAQ (Frequently Asked Questions) -CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and the "Calendar" client for CalDav is available since [v2.5.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.5.0). [Check this tag/release for custom-made server code where it was all mostly working, except for many edge cases, if you're interested](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). - -In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s //collections/collection-root// //collections/collection-root//`). - -> [!NOTE] -> If you're running radicale with docker, the symlink needs to point to the container's directory, usually starting with `/data` if you didn't change the `radicale-config/config`, otherwise the container will fail to load the linked directory. - -## How does private file sharing work? - -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, if you're interested](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. - -> [!NOTE] -> If you're running the app with docker, the symlink needs to point to the container's directory, usually starting with `/app` if you didn't change the `Dockerfile`, otherwise the container will fail to load the linked directory. +[Check the FAQ](/FAQ.md) for answers to common questions, like private calendar and file sharing, or `.env`-based configuration. ## How does it look? @@ -122,5 +108,7 @@ You can simply `ln -s /// route.startsWith(menu.url)); let pageLabel = activeMenu?.label || '404 - Page not found'; diff --git a/docker-compose.yml b/docker-compose.yml index e0dd98c..ddff649 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: website: - image: ghcr.io/bewcloud/bewcloud:v3.0.1 + image: ghcr.io/bewcloud/bewcloud:v3.0.2 restart: always ports: - 127.0.0.1:8000:8000 diff --git a/lib/models/files.ts b/lib/models/files.ts index 8d17830..ee96ab3 100644 --- a/lib/models/files.ts +++ b/lib/models/files.ts @@ -701,6 +701,14 @@ async function deleteDirectoryOrFile(userId: string, path: string, name: string) const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); + const fileShares = (await AppConfig.isPublicFileSharingAllowed()) + ? await FileShareModel.getByParentFilePath(userId, path) + : []; + + const fileSharesForPath = fileShares.filter((fileShare) => + fileShare.file_path === `${join(path, name)}/` || fileShare.file_path === join(path, name) + ); + try { if (path.startsWith(TRASH_PATH)) { await Deno.remove(join(rootPath, name), { recursive: true }); @@ -708,6 +716,11 @@ async function deleteDirectoryOrFile(userId: string, path: string, name: string) const trashPath = join(await AppConfig.getFilesRootPath(), userId, TRASH_PATH); await Deno.rename(join(rootPath, name), join(trashPath, name)); } + + // Delete all file shares for this path + for (const fileShare of fileSharesForPath) { + await FileShareModel.delete(fileShare.id); + } } catch (error) { console.error(error); return false; diff --git a/routes/calendar/[calendarEventId].tsx b/routes/calendar/[calendarEventId].tsx index f38f431..6adfd54 100644 --- a/routes/calendar/[calendarEventId].tsx +++ b/routes/calendar/[calendarEventId].tsx @@ -49,7 +49,7 @@ export const handler: Handlers = { const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId); if (!calendarEvent) { - return new Response('Not found', { status: 404 }); + return context.renderNotFound(); } const calendars = await CalendarModel.list(context.state.user.id); @@ -83,7 +83,7 @@ export const handler: Handlers = { const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId); if (!calendarEvent) { - return new Response('Not found', { status: 404 }); + return context.renderNotFound(); } const calendars = await CalendarModel.list(context.state.user.id); diff --git a/routes/contacts/[contactId].tsx b/routes/contacts/[contactId].tsx index 72119a2..14ffa3f 100644 --- a/routes/contacts/[contactId].tsx +++ b/routes/contacts/[contactId].tsx @@ -38,7 +38,7 @@ export const handler: Handlers = { const contact = await ContactModel.get(context.state.user.id, addressBookId, contactId); if (!contact) { - return new Response('Not found', { status: 404 }); + return context.renderNotFound(); } return await context.render({ contact, formData: {}, addressBookId }); @@ -64,7 +64,7 @@ export const handler: Handlers = { const contact = await ContactModel.get(context.state.user.id, addressBookId, contactId); if (!contact) { - return new Response('Not found', { status: 404 }); + return context.renderNotFound(); } const formData = await request.formData(); diff --git a/routes/file-share/[fileShareId].tsx b/routes/file-share/[fileShareId].tsx index f693f69..b355a30 100644 --- a/routes/file-share/[fileShareId].tsx +++ b/routes/file-share/[fileShareId].tsx @@ -24,25 +24,25 @@ export const handler: Handlers = { const { fileShareId } = context.params; if (!fileShareId) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); if (!isPublicFileSharingAllowed) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const baseUrl = (await AppConfig.getConfig()).auth.baseUrl; if (!(await AppConfig.isAppEnabled('files'))) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const fileShare = await FileShareModel.getById(fileShareId); if (!fileShare) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const searchParams = new URL(request.url).searchParams; diff --git a/routes/file-share/[fileShareId]/open/[fileName].tsx b/routes/file-share/[fileShareId]/open/[fileName].tsx index 37919e5..68113ad 100644 --- a/routes/file-share/[fileShareId]/open/[fileName].tsx +++ b/routes/file-share/[fileShareId]/open/[fileName].tsx @@ -12,23 +12,23 @@ export const handler: Handlers = { const { fileShareId, fileName } = context.params; if (!fileShareId || !fileName) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); if (!isPublicFileSharingAllowed) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!(await AppConfig.isAppEnabled('files'))) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const fileShare = await FileShareModel.getById(fileShareId); if (!fileShare) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (fileShare.extra.hashed_password) { @@ -71,7 +71,7 @@ export const handler: Handlers = { const fileResult = await FileModel.get(fileShare.user_id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } return new Response(fileResult.contents! as BodyInit, { diff --git a/routes/file-share/[fileShareId]/verify.tsx b/routes/file-share/[fileShareId]/verify.tsx index 678266c..d749ab5 100644 --- a/routes/file-share/[fileShareId]/verify.tsx +++ b/routes/file-share/[fileShareId]/verify.tsx @@ -20,23 +20,23 @@ export const handler: Handlers = { const { fileShareId } = context.params; if (!fileShareId) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); if (!isPublicFileSharingAllowed) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!(await AppConfig.isAppEnabled('files'))) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const fileShare = await FileShareModel.getById(fileShareId); if (!fileShare) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!fileShare.extra.hashed_password) { @@ -49,23 +49,23 @@ export const handler: Handlers = { const { fileShareId } = context.params; if (!fileShareId) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); if (!isPublicFileSharingAllowed) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!(await AppConfig.isAppEnabled('files'))) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const fileShare = await FileShareModel.getById(fileShareId); if (!fileShare) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!fileShare.extra.hashed_password) { diff --git a/routes/files/open/[fileName].tsx b/routes/files/open/[fileName].tsx index d38454f..07db8a6 100644 --- a/routes/files/open/[fileName].tsx +++ b/routes/files/open/[fileName].tsx @@ -15,7 +15,7 @@ export const handler: Handlers = { const { fileName } = context.params; if (!fileName) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!(await AppConfig.isAppEnabled('files'))) { @@ -39,7 +39,7 @@ export const handler: Handlers = { const fileResult = await FileModel.get(context.state.user.id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } return new Response(fileResult.contents! as BodyInit, { diff --git a/routes/notes/open/[fileName].tsx b/routes/notes/open/[fileName].tsx index 97c69c8..6de558e 100644 --- a/routes/notes/open/[fileName].tsx +++ b/routes/notes/open/[fileName].tsx @@ -20,7 +20,7 @@ export const handler: Handlers = { const { fileName } = context.params; if (!fileName) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!(await AppConfig.isAppEnabled('notes'))) { @@ -43,13 +43,13 @@ export const handler: Handlers = { // Don't allow non-markdown files here if (!fileName.endsWith('.md')) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const fileResult = await FileModel.get(context.state.user.id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } return await context.render({ fileName, currentPath, contents: new TextDecoder().decode(fileResult.contents!) }); diff --git a/routes/photos/thumbnail/[fileName].tsx b/routes/photos/thumbnail/[fileName].tsx index 194848d..33c0659 100644 --- a/routes/photos/thumbnail/[fileName].tsx +++ b/routes/photos/thumbnail/[fileName].tsx @@ -21,7 +21,7 @@ export const handler: Handlers = { const { fileName } = context.params; if (!fileName) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } if (!(await AppConfig.isAppEnabled('photos'))) { @@ -45,7 +45,7 @@ export const handler: Handlers = { const fileResult = await FileModel.get(context.state.user.id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { - return new Response('Not Found', { status: 404 }); + return context.renderNotFound(); } const width = parseInt(searchParams.get('width') || '500', 10);