diff --git a/.gitignore b/.gitignore index f96df91..1b9cac0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ data-files/ # Config bewcloud.config.ts + +# Radicale files +data-radicale/ diff --git a/README.md b/README.md index 4af3dc6..34449b2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Download/copy [`docker-compose.yml`](/docker-compose.yml), [`.env.sample`](/.env > `1993:1993` below comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it. ```sh -$ mkdir data-files # local directory for storing user-uploaded files +$ mkdir data-files data-radicale # local directories for storing user-uploaded files and radicale data $ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files $ docker compose up -d # makes the app available at http://localhost:8000 $ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates) @@ -89,11 +89,14 @@ $ make build # generates all static files for production deploy Just push to the `main` branch. -## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement? +## How does Contacts/CardDav and Calendar/CalDav work? -[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. +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 clients are not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). -My focus was to get me to replace Nextcloud for me and my family ASAP, and it turns out it's not easy to do it all in a single, installable _thing_, so I focused on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient). +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? diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index 3dc82cb..33be9d5 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -28,6 +28,14 @@ const config: PartialDeep = { // host: 'localhost', // port: 465, // }, + // contacts: { + // enableCardDavServer: true, + // cardDavUrl: 'http://127.0.0.1:5232', + // }, + // calendar: { + // enableCalDavServer: true, + // calDavUrl: 'http://127.0.0.1:5232', + // }, }; export default config; diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx index d9dcd59..ff7d57a 100644 --- a/components/files/MainFiles.tsx +++ b/components/files/MainFiles.tsx @@ -48,11 +48,22 @@ interface MainFilesProps { initialPath: string; baseUrl: string; isFileSharingAllowed: boolean; + isCardDavEnabled?: boolean; + isCalDavEnabled?: boolean; fileShareId?: string; } export default function MainFiles( - { initialDirectories, initialFiles, initialPath, baseUrl, isFileSharingAllowed, fileShareId }: MainFilesProps, + { + initialDirectories, + initialFiles, + initialPath, + baseUrl, + isFileSharingAllowed, + isCardDavEnabled, + isCalDavEnabled, + fileShareId, + }: MainFilesProps, ) { const isAdding = useSignal(false); const isUploading = useSignal(false); @@ -879,6 +890,24 @@ export default function MainFiles( ) : null} + {!fileShareId && isCardDavEnabled + ? ( +
+ CardDav URL:{' '} + {baseUrl}/carddav +
+ ) + : null} + + {!fileShareId && isCalDavEnabled + ? ( +
+ CalDav URL:{' '} + {baseUrl}/caldav +
+ ) + : null} + {!fileShareId ? ( ); diff --git a/lib/config.ts b/lib/config.ts index b187bb3..1dfe177 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -36,6 +36,14 @@ export class AppConfig { host: 'localhost', port: 465, }, + contacts: { + enableCardDavServer: true, + cardDavUrl: 'http://127.0.0.1:5232', + }, + calendar: { + enableCalDavServer: true, + calDavUrl: 'http://127.0.0.1:5232', + }, }; } @@ -176,4 +184,16 @@ export class AppConfig { return this.config.email; } + + static async getContactsConfig(): Promise { + await this.loadConfig(); + + return this.config.contacts; + } + + static async getCalendarConfig(): Promise { + await this.loadConfig(); + + return this.config.calendar; + } } diff --git a/lib/types.ts b/lib/types.ts index f67627b..aff5358 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -200,6 +200,18 @@ export interface Config { /** The SMTP port to send emails from */ port: number; }; + contacts: { + /** If true, the CardDAV server will be enabled (proxied) */ + enableCardDavServer: boolean; + /** The CardDAV server URL to proxy to */ + cardDavUrl: string; + }; + calendar: { + /** If true, the CalDAV server will be enabled (proxied) */ + enableCalDavServer: boolean; + /** The CalDAV server URL to proxy to */ + calDavUrl: string; + }; } export type MultiFactorAuthMethodType = 'totp' | 'passkey' | 'email'; diff --git a/radicale-config/config b/radicale-config/config new file mode 100644 index 0000000..e546417 --- /dev/null +++ b/radicale-config/config @@ -0,0 +1,149 @@ +# -*- mode: conf -*- +# vim:ft=cfg + +# Config file for Radicale - A simple calendar server + +[server] + +# CalDAV server hostnames separated by a comma +# IPv4 syntax: address:port +# IPv6 syntax: [address]:port +hosts = 0.0.0.0:5232 + +# Max parallel connections +#max_connections = 8 + +# Max size of request body (bytes) +#max_content_length = 100000000 + +# Socket timeout (seconds) +#timeout = 30 + + +[encoding] + +# Encoding for responding requests +#request = utf-8 + +# Encoding for storing local collections +#stock = utf-8 + + +[auth] + +# Authentication method +#type = none +type = http_x_remote_user + +# Cache logins for until expiration time +#cache_logins = false + +# Expiration time for caching successful logins in seconds +#cache_successful_logins_expiry = 15 + +## Expiration time of caching failed logins in seconds +#cache_failed_logins_expiry = 90 + +# IMAP server hostname +# Syntax: address | address:port | [address]:port | imap.server.tld +#imap_host = localhost + +# Secure the IMAP connection +# Value: tls | starttls | none +#imap_security = tls + +# OAuth2 token endpoint URL +#oauth2_token_endpoint = + + +[rights] + +# Rights backend +# Value: authenticated | owner_only | owner_write | from_file +#type = owner_only + +# File for rights management from_file +#file = /etc/radicale/rights + +# Permit delete of a collection (global) +#permit_delete_collection = True + +# Permit overwrite of a collection (global) +#permit_overwrite_collection = True + +# URL Decode the given username (when URL-encoded by the client - useful for iOS devices when using email address) +# urldecode_username = False + +[storage] + +# Storage backend +# Value: multifilesystem | multifilesystem_nolock +#type = multifilesystem + +# Folder for storing local collections, created if not present +filesystem_folder = /data/collections + +# Delete sync token that are older (seconds) +#max_sync_token_age = 2592000 + +# Skip broken item instead of triggering an exception +#skip_broken_item = True + +[web] + +# Web interface backend +# Value: none | internal +#type = internal + + +[logging] + +# Threshold for the logger +# Value: debug | info | warning | error | critical +#level = info + +# Don't include passwords in logs +#mask_passwords = True + +# Log bad PUT request content +#bad_put_request_content = False + +# Log backtrace on level=debug +#backtrace_on_debug = False + +# Log request header on level=debug +#request_header_on_debug = False + +# Log request content on level=debug +#request_content_on_debug = False + +# Log response content on level=debug +#response_content_on_debug = False + +# Log rights rule which doesn't match on level=debug +#rights_rule_doesnt_match_on_debug = False + +# Log storage cache actions on level=debug +#storage_cache_actions_on_debug = False + +[headers] + +# Additional HTTP headers +#Access-Control-Allow-Origin = * + + +[hook] + +# Hook types +# Value: none | rabbitmq +#type = none +#rabbitmq_endpoint = +#rabbitmq_topic = +#rabbitmq_queue_type = classic + + +[reporting] + +# When returning a free-busy report, limit the number of returned +# occurences per event to prevent DOS attacks. +#max_freebusy_occurrence = 10000 diff --git a/routes/.well-known/caldav.tsx b/routes/.well-known/caldav.tsx new file mode 100644 index 0000000..f1eaf94 --- /dev/null +++ b/routes/.well-known/caldav.tsx @@ -0,0 +1,9 @@ +import { Handler } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; + +interface Data {} + +export const handler: Handler = () => { + return new Response('Redirecting...', { status: 301, headers: { 'Location': '/caldav' } }); +}; diff --git a/routes/.well-known/carddav.tsx b/routes/.well-known/carddav.tsx new file mode 100644 index 0000000..18f9e96 --- /dev/null +++ b/routes/.well-known/carddav.tsx @@ -0,0 +1,9 @@ +import { Handler } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; + +interface Data {} + +export const handler: Handler = () => { + return new Response('Redirecting...', { status: 301, headers: { 'Location': '/carddav' } }); +}; diff --git a/routes/_middleware.tsx b/routes/_middleware.tsx index 6183a16..4ac344b 100644 --- a/routes/_middleware.tsx +++ b/routes/_middleware.tsx @@ -7,7 +7,10 @@ export const handler = [ async function handleCors(request: Request, context: FreshContext) { const path = new URL(request.url).pathname; - if (request.method == 'OPTIONS' && path !== '/dav' && !path.startsWith('/dav/')) { + if ( + request.method == 'OPTIONS' && path !== '/dav' && !path.startsWith('/dav/') && path !== '/carddav' && + !path.startsWith('/carddav/') && path !== '/caldav' && !path.startsWith('/caldav/') + ) { const response = new Response(null, { status: 204, }); @@ -68,10 +71,14 @@ export const handler = [ const response = await context.next(); console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`); - // NOTE: Uncomment when debugging WebDav stuff - // if (request.url.includes('/dav')) { + // NOTE: Uncomment when debugging WebDav/CardDav/CalDav stuff + // if (request.url.includes('/dav') || request.url.includes('/carddav') || request.url.includes('/caldav')) { // console.info(`Request`, request.headers); - // console.info((await request.clone().text()) || ''); + // try { + // console.info((await request.clone().text()) || ''); + // } catch (_error) { + // console.info(''); + // } // console.info(`Response`, response.headers); // console.info(`Status`, response.status); // } diff --git a/routes/caldav.tsx b/routes/caldav.tsx new file mode 100644 index 0000000..739dbba --- /dev/null +++ b/routes/caldav.tsx @@ -0,0 +1,69 @@ +import { Handler, RouteConfig } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AppConfig } from '/lib/config.ts'; + +interface Data {} + +export const config: RouteConfig = { + routeOverride: '/caldav/:path*', +}; + +export const handler: Handler = async (request, context) => { + const calendarConfig = await AppConfig.getCalendarConfig(); + + if (!calendarConfig.enableCalDavServer) { + return new Response('Not Found', { status: 404 }); + } + + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + const { path } = context.params; + + const userId = context.state.user.id; + + try { + const requestBodyText = await request.clone().text(); + + // Remove the `/caldav/` prefix from the hrefs in the request + const parsedRequestBodyText = requestBodyText.replaceAll('/caldav/', `/`).replaceAll( + ':href>/caldav/', + `:href>/`, + ); + + const response = await fetch(`${calendarConfig.calDavUrl}/${path}`, { + headers: { + ...Object.fromEntries(request.headers.entries()), + 'X-Remote-User': `${userId}`, + }, + method: request.method, + body: parsedRequestBodyText, + }); + + if (response.status === 204) { + return new Response(null, { status: 204 }); + } + + const responseBodyText = await response.clone().text(); + + // Add the `/caldav/` prefix to the hrefs in the response + const parsedBodyResponseText = responseBodyText.replaceAll('/', `/caldav/`).replaceAll( + ':href>/', + `:href>/caldav/`, + ); + + return new Response(parsedBodyResponseText, { + status: response.status, + headers: response.headers, + }); + } catch (error) { + console.error(error); + } + + return new Response(null, { status: 405 }); +}; diff --git a/routes/carddav.tsx b/routes/carddav.tsx new file mode 100644 index 0000000..07994e3 --- /dev/null +++ b/routes/carddav.tsx @@ -0,0 +1,69 @@ +import { Handler, RouteConfig } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { AppConfig } from '/lib/config.ts'; + +interface Data {} + +export const config: RouteConfig = { + routeOverride: '/carddav/:path*', +}; + +export const handler: Handler = async (request, context) => { + const contactsConfig = await AppConfig.getContactsConfig(); + + if (!contactsConfig.enableCardDavServer) { + return new Response('Not Found', { status: 404 }); + } + + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + const { path } = context.params; + + const userId = context.state.user.id; + + try { + const requestBodyText = await request.clone().text(); + + // Remove the `/carddav/` prefix from the hrefs in the request + const parsedRequestBodyText = requestBodyText.replaceAll('/carddav/', `/`).replaceAll( + ':href>/carddav/', + `:href>/`, + ); + + const response = await fetch(`${contactsConfig.cardDavUrl}/${path}`, { + headers: { + ...Object.fromEntries(request.headers.entries()), + 'X-Remote-User': `${userId}`, + }, + method: request.method, + body: parsedRequestBodyText, + }); + + if (response.status === 204) { + return new Response(null, { status: 204 }); + } + + const responseBodyText = await response.clone().text(); + + // Add the `/carddav/` prefix to the hrefs in the response + const parsedBodyResponseText = responseBodyText.replaceAll('/', `/carddav/`).replaceAll( + ':href>/', + `:href>/carddav/`, + ); + + return new Response(parsedBodyResponseText, { + status: response.status, + headers: response.headers, + }); + } catch (error) { + console.error(error); + } + + return new Response(null, { status: 405 }); +}; diff --git a/routes/files.tsx b/routes/files.tsx index dd37e77..1b7986a 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -11,6 +11,8 @@ interface Data { currentPath: string; baseUrl: string; isFileSharingAllowed: boolean; + isCardDavEnabled: boolean; + isCalDavEnabled: boolean; } export const handler: Handlers = { @@ -40,6 +42,11 @@ export const handler: Handlers = { const userFiles = await FileModel.list(context.state.user.id, currentPath); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); + const contactsConfig = await AppConfig.getContactsConfig(); + const calendarConfig = await AppConfig.getCalendarConfig(); + + const isCardDavEnabled = contactsConfig.enableCardDavServer; + const isCalDavEnabled = calendarConfig.enableCalDavServer; return await context.render({ userDirectories, @@ -47,6 +54,8 @@ export const handler: Handlers = { currentPath, baseUrl, isFileSharingAllowed: isPublicFileSharingAllowed, + isCardDavEnabled, + isCalDavEnabled, }); }, }; @@ -60,6 +69,8 @@ export default function FilesPage({ data }: PageProps) initialPath={data.currentPath} baseUrl={data.baseUrl} isFileSharingAllowed={data.isFileSharingAllowed} + isCardDavEnabled={data.isCardDavEnabled} + isCalDavEnabled={data.isCalDavEnabled} /> );