Misc fixes for file shares

Remove file share when deleting a file/directory (#121)

Keep a consistent logged-out-view of file shares (#123)

Simplify README, add FAQ with more info, including `.env`-based config (#90)

Closes #121
Closes #123
Closes #90
This commit is contained in:
Bruno Bernardino 2025-12-08 09:17:42 +00:00
parent cfa21e6089
commit a68bdba4b5
No known key found for this signature in database
GPG key ID: D1B0A69ADD114ECE
13 changed files with 273 additions and 55 deletions

217
FAQ.md Normal file
View file

@ -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 /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
> [!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 /<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.
> [!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.
<details>
<summary>
bewcloud.config.ts
</summary>
```ts
import { Config, OptionalApp, PartialDeep } from './lib/types.ts';
// Check the Config type for all the possible options and instructions.
function requireValue<T>(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<Config> = {
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;
```
</details>
Append the following to your `.env` file
<details>
<summary> .env </summary>
```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
```
</details>

View file

@ -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 /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
> [!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 /<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.
> [!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 /<absolute-path-to-data-files>/<owner-user-id>/<directory-
## Community Links
* [`bewcloud-nixos`](https://gitlab.com/ntninja/bewcloud-nixos/) by @ntninja exposes bewCloud as a NixOS integration as an alternative to using Docker or running the app locally.
* For installation and known limitations, please see its [README](https://gitlab.com/ntninja/bewcloud-nixos/-/blob/main/README.md).
These are not officially endorsed, but are alternative ways of running bewCloud.
- [`bewcloud-nixos`](https://gitlab.com/ntninja/bewcloud-nixos/) by [@ntninja](https://github.com/ntninja) exposes bewCloud as a NixOS integration as an alternative to using Docker or running the app locally.
- For installation and known limitations, please see its [README](https://gitlab.com/ntninja/bewcloud-nixos/-/blob/main/README.md).

View file

@ -31,7 +31,7 @@ export default function Header({ route, user, enabledApps }: Data) {
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
if (user) {
if (user && !route.startsWith('/file-share')) {
const activeMenu = menuItems.find((menu) => route.startsWith(menu.url));
let pageLabel = activeMenu?.label || '404 - Page not found';

View file

@ -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

View file

@ -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;

View file

@ -49,7 +49,7 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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);

View file

@ -38,7 +38,7 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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();

View file

@ -24,25 +24,25 @@ export const handler: Handlers<Data, FreshContextState> = {
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;

View file

@ -12,23 +12,23 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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, {

View file

@ -20,23 +20,23 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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) {

View file

@ -15,7 +15,7 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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, {

View file

@ -20,7 +20,7 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
// 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!) });

View file

@ -21,7 +21,7 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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);