Compare commits

...

50 commits
v2.5.3 ... main

Author SHA1 Message Date
Bruno Bernardino
6f871b72ad
Return 404 on WebDAV MOVE request if source doesn't exist
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Run Tests / test (push) Has been cancelled
Fixes #155
2026-03-03 15:40:47 +00:00
Bruno Bernardino
f50423028b
Implement max file upload config options and simplify request method handling
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Deploy / deploy (push) Waiting to run
Run Tests / test (push) Waiting to run
Fixes #154
Related to #155
2026-03-02 19:52:00 +00:00
Bruno Bernardino
69a916d709
Fix typo, update default version
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Run Tests / test (push) Has been cancelled
2026-02-26 13:35:33 +00:00
Piotr Łoboda
0d20b4a337
Enable SSO signups (#152)
* feat: enableSingleSignOnSignUp support

* chore: simplify return statement

* chore: linted

* feat: verbose error on not enabled SSO sign up

* chore: update config key to match others

* chore: add key to config sample

* chore: apply suggestions from code review

Co-authored-by: BrunoBernardino <me@brunobernardino.com>

---------

Co-authored-by: BrunoBernardino <me@brunobernardino.com>
2026-02-26 13:31:19 +00:00
Bruno Bernardino
917649b97a
Fix ring opacity in dropdowns
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Deploy / deploy (push) Waiting to run
Run Tests / test (push) Waiting to run
2026-02-25 15:14:18 +00:00
Bruno Bernardino
5d394045dd
Merge pull request #150 from loboda4450/main
feat: update README.md to include myself with helm chart
2026-02-25 14:35:29 +00:00
Piotr Łoboda
264d4da6e5 feat: update README.md to include myself 2026-02-25 01:01:14 +01:00
Bruno Bernardino
ec334fb8fa
Update managed cloud buy URL
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Deploy / deploy (push) Waiting to run
Run Tests / test (push) Waiting to run
2026-02-24 14:45:31 +00:00
Bruno Bernardino
bd141bf9dc
Update default docker image to v4.0.2
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Deploy / deploy (push) Waiting to run
Run Tests / test (push) Waiting to run
2026-02-23 17:32:41 +00:00
不做了睡大觉
1aca444b22
fix: properly strip HTML tags and resolve entities in feed article summaries (#149)
* fix: properly strip HTML tags and resolve entities in feed article summaries

Fixes #146

The parseTextFromHtml function was using document.textContent directly on
the parsed HTML document, which could leave raw HTML tags and unresolved
entities in feed article summaries.

Changes:
- Extract text from body element to avoid document wrapper artifacts
- Collapse multiple whitespace/newlines into single spaces for cleaner output
- Add early return for empty/whitespace-only input
- Use optional chaining for safer null handling

* fix: preserve single line breaks, only collapse 2+ consecutive whitespace

Address review feedback: the previous \s+ regex was too aggressive and
broke text-only summaries with legitimate line breaks.

Now:
- Collapse runs of 2+ non-newline whitespace into a single space
- Collapse 3+ consecutive newlines into double newline (paragraph break)
- Single line breaks are preserved

---------

Co-authored-by: User <user@example.com>
2026-02-23 17:29:09 +00:00
Bruno Bernardino
6d5ee7b53c
Fix logout page typo
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Run Tests / test (push) Has been cancelled
Fixes #148
2026-02-21 07:18:04 +00:00
Bruno Bernardino
6ef42d902c
Merge pull request #143 from bewcloud/feature/remove-fresh
Some checks are pending
Build Docker Image / build-and-push (push) Waiting to run
Deploy / deploy (push) Waiting to run
Run Tests / test (push) Waiting to run
Remove fresh
2026-02-20 10:59:34 +00:00
Bruno Bernardino
c26cae625e
Remove fresh
This implements a huge change, where Fresh is removed as a framework and serving files, allowing more control over importing, bundling, and serving files and components.

The biggest challenge was to continue making sure that there weren't too many places to look into for import versions, and `PasswordlessPasskeyLogin.tsx` became a prototype in migrating a component to fully SSR, no need for frontend parsing (via Babel) or bundling (via a custom-script, downloading frontend dependencies from esm.sh). Still, there are too many components to migrate like that, and it's all working, so I likely won't even attempt it unless there's some bug, new feature, or security vulnerability to address that warrants a rewrite of those.

This also updates all dependencies (except `@libs/xml` because that still causes some breaking in DAV endpoints), including Deno!

All other advantages can be seen in the related issues, and the breaking change this (v4.0.0) introduces is related simply to `config.email.tlsMode` (which had a deprecation warning throughout v3), and because, while I tested many things exhaustively, it's not impossible something broke that I didn't see.

Closes #141
Closes #132
2026-02-20 10:54:31 +00:00
Bruno Bernardino
770db3a605
Revert Deno version and unnecessary sub-dependencies. Fresh 1.7.3 won't play nice with Deno 2.6.x
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Run Tests / test (push) Has been cancelled
I guess that'll keep this at Deno 2.5.x unless something terrible happens, because as per #99 we can't easily upgrade fresh.
2026-02-02 17:12:02 +00:00
Bruno Bernardino
290cf6ea4c
Update deno and dependencies 2026-02-02 16:57:17 +00:00
Bruno Bernardino
fb2a7d5cce
Implement basic directory sizes using du
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Run Tests / test (push) Has been cancelled
Closes #112
2026-01-18 16:59:53 +00:00
Bruno Bernardino
bfd4851098
Also fix calendar event imports with multiline fields
Fixes #139
2026-01-02 10:40:40 +00:00
Bruno Bernardino
0a7e03326f
Update default docker version 2026-01-02 10:22:28 +00:00
Bruno Bernardino
3ffd3328a1
Fix vCard importing with multiline fields.
Fixes #139
2026-01-02 10:21:49 +00:00
Erin of Yukis
777a26f492
Add systemd-compatible service manager notification after bewCloud has successfully started (#137)
* Add systemd-compatible service manager notification after bewCloud has successfully started

* Use `systemd-notify` util instead of native integration while Deno APIs aren’t there yet

* Implement different approach for systemd-notification reporting

I went with a slightly different option, given I was struggling to lose a lot of flexibility in the original listening log (because it's started with some server state parameters) or allow so much unnecessary/duplicate complexity from Fresh in `fresh.config.ts` because I'll probably eventually ditch it (given #99).

Some relevant references in the original `fresh` code:

- ab14d1044c/docs/1.x/concepts/server-configuration.md (L207-L208)
- d9764e2005/src/server/config.ts (L95)
- d9764e2005/src/server/mod.ts (L115-L118)
- d9764e2005/src/server/boot.ts (L52-L57)

* Remove unnecessary comments

---------

Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
2026-01-02 10:15:25 +00:00
Bruno Bernardino
69995c422e
Merge pull request #138 from ntninja/docs-readme-nixos-module
docs: Update bewcloud-nixos link and description
2025-12-29 17:18:56 +00:00
Erin of Yukis
54852181ab docs: Update bewcloud-nixos link and description
It’s now actually Docker-level easy to use (no more manual installation) and also hosted on Codeberg.
2025-12-29 17:45:37 +01:00
Bruno Bernardino
01837e4966
Update default release in docker 2025-12-29 11:12:36 +00:00
Erin of Yukis
f64751956a
Declare deno task run-with-perms task specifying all the permissions actually needed and alias all other tasks through that (#136)
* Declare `deno task execute-with-permissions` task specifying all the permissions actually needed and alias all other tasks through that

Also add `migrate-db` task to the Deno configuration and use that in the
`Makefile`, so that the Makefile is fully optional, and swap the meanings of
the `start` and `preview` tasks, so that `start` is for production while
`preview` is for development.

* Keep task names consistent (no breaking changes)

* Reorder tasks

* Remove empty lines

* Use correct task in Dockerfile

* Bring back start (no breaking changes)

* Update readme with preview command

* Update necessary permissions for running locally and in docker

---------

Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
2025-12-29 11:07:42 +00:00
Bruno Bernardino
c9c5364ca9
Change default install version 2025-12-20 12:02:45 +00:00
Bruno Bernardino
a0935dd8b9
Fix minor errors and update default install version 2025-12-20 12:01:16 +00:00
Erin of Yukis
d547948865
Expose new tlsMode and tlsVerify options for connecting to the mail submission agent (#134)
* Expose new `tlsMode` and `tlsVerify` options for connecting to the mail submission agent

* Make `tlsVerify` default to opportunistic StartTLS on ports other than 465 to prevent breaking change

---------

Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
2025-12-20 11:50:15 +00:00
Bruno Bernardino
624fdb69f4
Update latest release 2025-12-15 14:41:46 +00:00
Erin of Yukis
05cae80c12
Remove all uses of Google Services (#133)
* Use OS default Sans-Serif font instead of Google Nunito Sans Font

* Link calendar event locations to OSM instead of Google Maps

Better would be to use https://www.mediawiki.org/wiki/GeoHack – which is used
by WikiPedia to show an interactive selector for the mapping service to use –,
but it requires geo coordinates. Some systems also support generic geo:-URIs,
but they require coodinates and outside Android support is pretty spotty
(Apple wants you to use Apple Maps links instead, desktop platforms generally
require installed third-party apps …). Android does support an extension (`?q=`)
(https://developer.android.com/guide/components/intents-common#Maps) to search
by address via geo:-links though.
2025-12-15 14:29:16 +00:00
Bruno Bernardino
dcac5d8c44
Migrate from sharp to jimp
This avoids native/binary problems like #131 and #115 at the expense of a bit of performance.

Fixes #131
Fixes #115
2025-12-12 16:03:16 +00:00
Bruno Bernardino
a68bdba4b5
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
2025-12-08 09:17:42 +00:00
Erin of Yukis
cfa21e6089
Add reference to bewcloud-nixos to README.md (#126)
* Add reference to `bewcloud-nixos` to README.md

As requested in https://github.com/bewcloud/bewcloud/pull/125#issuecomment-3612270900.

* Add suggestion

* Add suggestion

---------

Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
2025-12-05 17:12:41 +00:00
Bruno Bernardino
87d6904261
Linting and update compose image version 2025-12-03 14:56:40 +00:00
Bruno Bernardino
e44a7a61e6
Merge pull request #120 from ntninja/fix-root-path-absolute
fix: Don’t forcefully make absolute rootPath relative to CWD
2025-12-03 14:45:03 +00:00
Erin of Yukis
829189d0b3 fix: Don’t forcefully make absolute rootPath relative to CWD 2025-12-03 06:47:53 +01:00
Bruno Bernardino
31855f4802
Merge pull request #118 from bewcloud/feature/improve-app-selection
Improve App Selection
2025-12-01 12:30:20 +00:00
Bruno Bernardino
d86e65475c
Improve App Selection
This allows not enabling Dashboard and Files. It also sorts the apps in the menu according to the order in the `config.core.enabledApps` array.

Since this will require a major version upgrade (`v3.0.0`), I also took the opportunity to upgrade PostgreSQL. You can [follow this guide on how to upgrade PostgreSQL on Docker containers](https://news.onbrn.com/step-by-step-guide-upgrading-postgresql-docker-containers/).

Finally, this has some minor security improvements (confirming API endpoints won't work if their app is disabled in the config).

Closes #114
Closes #108
2025-12-01 12:25:21 +00:00
Bruno Bernardino
3fdda5b34e
Update Deno version 2025-11-08 10:59:55 +00:00
Bruno Bernardino
6f7c534e59
Update deno.lock file 2025-11-08 10:52:02 +00:00
Bruno Bernardino
8e1b9d1d70
Merge pull request #113 from themadbit/generate-lockfile
generate lock file
2025-11-04 14:32:44 +00:00
themadbit
86721d8877 generate lock file 2025-11-04 12:03:50 +03:00
Bruno Bernardino
b2dda31c51
Revert xml lib, to avoid unexpected issues 2025-10-17 20:52:07 +01:00
Bruno Bernardino
6280228759
Fix XML parsing for WebDav
This was a regression caused by the `@libs/xml` upgrade in v2.6.0
2025-10-17 20:41:01 +01:00
Bruno Bernardino
8d78e1f25c
Upgrade dependencies, fix directory download errors
Related to #106
2025-10-08 14:38:31 +01:00
Tilman
c4a5166e3b
Support downloading directories as a zip archive (#106)
* Add directory download as zip feature

Implements the ability for users to download directories as zip files if enabled in config. Adds a new API route for directory zipping, updates UI components to show a download button for directories, and introduces related config and type changes. Also includes a new download icon.

* Windows path bugfix

* Include empty directories in zip archive

* Address feedback

- `isDirectoryDownloadsAllowed` -> `areDirectoryDownloadsAllowed`
- send `parentPath` & `name` to API instead of resolving `fullPath` on client
- call `ensureUserPathIsValidAndSecurelyAccessible` before zipping
- set config `allowDirectoryDownloads` default to `false`
- add `zip` to Dockerfile and replace in-house zip algorithm
- replace `download.svg` with heroicon's `arrow-down-tray`
- `replace` with glob -> `replaceAll` with string

* Cleanup apt-get command

* Remove unused zip archive and directory functions
2025-10-08 14:32:45 +01:00
Bruno Bernardino
c81ef77370
Fix linting 2025-10-01 14:20:51 +01:00
Bruno Bernardino
1dcbf529a3
Make initial News loading faster 2025-10-01 14:17:39 +01:00
Tilman
adde693585
Merge pull request #103 from medallyon/bugfix/101-ignore-case-for-sorting
Make file and directory sorting case-insensitive
2025-10-01 14:16:58 +01:00
Bruno Bernardino
577fe087f2
Merge pull request #100 from bewcloud/feature/upgrade-dependencies
Update all dependencies
2025-09-27 19:57:52 +01:00
Bruno Bernardino
6734e9557b
Update all dependencies
This takes part of the work being done in #96 that was reverted but still useful.

Note Tailwind and Fresh weren't upgraded because there's no security vulnerability in either, and I have found the new versions to be worse in performance. Thos will likely stay at those fixed versions going forward.
2025-09-27 19:39:09 +01:00
474 changed files with 33572 additions and 8170 deletions

View file

@ -1,14 +1,11 @@
.do
.github .github
.git .git
.gitignore .gitignore
docker-compose* docker-compose*
Dockerfile Dockerfile
render.yaml
LICENSE LICENSE
README.md README.md
.env.sample .env.sample
node_modules node_modules
_fresh
data-files data-files
bewcloud.config.sample.ts bewcloud.config.sample.ts

2
.dvmrc
View file

@ -1 +1 @@
2.4.5 2.6.10

View file

@ -16,5 +16,5 @@ MFA_SALT="fake" # optional, if you want to enable multi-factor authentication
OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On) OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On)
OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On) OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On)
SMTP_USERNAME="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email #SMTP_USERNAME="" # optional, if you want to use signup email verification or multi-factor with an email service requiring authentication
SMTP_PASSWORD="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email #SMTP_PASSWORD="" # optional, if you want to use signup email verification or multi-factor with an email service requiring authentication

2
.github/FUNDING.yml vendored
View file

@ -1,7 +1,7 @@
github: [bewcloud, BrunoBernardino] github: [bewcloud, BrunoBernardino]
custom: custom:
[ [
'https://donate.stripe.com/bIYeWBbw00Ape5iaFi', 'https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5',
'https://paypal.me/brunobernardino', 'https://paypal.me/brunobernardino',
'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09', 'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09',
] ]

View file

@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v3 uses: docker/login-action@v3

View file

@ -10,7 +10,7 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Configure SSH - name: Configure SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/

View file

@ -6,7 +6,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: denoland/setup-deno@v2 - uses: denoland/setup-deno@v2
with: with:
deno-version-file: .dvmrc deno-version-file: .dvmrc

3
.gitignore vendored
View file

@ -1,6 +1,3 @@
# Fresh build directory
_fresh/
# npm dependencies # npm dependencies
node_modules/ node_modules/

View file

@ -1,25 +1,25 @@
FROM denoland/deno:ubuntu-2.4.5 FROM denoland/deno:ubuntu-2.6.10
EXPOSE 8000 EXPOSE 8000
RUN apt-get update && apt-get install -y make RUN apt-get update && apt-get install -y make zip coreutils
WORKDIR /app WORKDIR /app
# These steps will be re-run upon each file change in your working directory:
ADD . /app ADD . /app
RUN rm -fr node_modules _fresh # Prepare for any npm modules required "on the fly"
RUN mkdir -p /app/node_modules/.deno
# Build fresh RUN chown -R deno:deno /app
RUN deno task build
RUN chown -R deno:deno /app /deno-dir
# Prefer not to run as root. # Prefer not to run as root.
USER deno USER deno
# Build frontend components and CSS
RUN deno task build
# Compile the main app so that it doesn't need to be compiled each startup/entry. # Compile the main app so that it doesn't need to be compiled each startup/entry.
RUN deno cache --reload main.ts RUN deno cache --reload main.ts
CMD ["run", "--allow-all", "main.ts"] CMD ["task", "preview"]

221
FAQ.md Normal file
View file

@ -0,0 +1,221 @@
# 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>
## Wasn't this made with Fresh?
Up until [v4.0.0](https://github.com/bewcloud/bewcloud/releases/tag/v4.0.0), bewCloud was made with [Fresh](https://fresh.deno.dev/), but as per [#141](https://github.com/bewcloud/bewcloud/issues/141) it has since been rewritten in standard Deno to use a more web-standards-based backend, easier to maintain and extend, without requiring breaking updates.

View file

@ -1,3 +1,5 @@
SHELL := /bin/bash
.PHONY: start .PHONY: start
start: start:
deno task start deno task start
@ -13,16 +15,40 @@ test:
.PHONY: build .PHONY: build
build: build:
deno task build make download-frontend-imports
make build-babel
make build-tailwind
.PHONY: download-frontend-imports
download-frontend-imports:
deno task download-frontend-imports
.PHONY: build-babel
build-babel:
deno run --allow-env --allow-ffi --allow-sys --allow-read --allow-write=public/components npm:@babel/cli@7.28.6/babel ./components --out-dir ./public/components --extensions ".ts,.tsx"
.PHONY: watch-babel
watch-babel:
deno run --allow-env --allow-ffi --allow-sys --allow-read --allow-write=public/components npm:@babel/cli@7.28.6/babel ./components --out-dir ./public/components --extensions ".ts,.tsx" --watch
.PHONY: migrate-db .PHONY: migrate-db
migrate-db: migrate-db:
deno run --allow-net --allow-read --allow-env migrate-db.ts deno task migrate-db
.PHONY: crons/cleanup
crons/cleanup:
deno run --allow-net --allow-read --allow-env crons/cleanup.ts
.PHONY: exec-db .PHONY: exec-db
exec-db: exec-db:
docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql
.PHONY: build-tailwind
build-tailwind:
deno install --allow-scripts npm:tailwindcss@4.2.0 npm:@tailwindcss/cli@4.2.0
deno run --allow-env --allow-read --allow-sys --allow-ffi --vendor --unstable-detect-cjs --allow-write=public/css,/var/folders --allow-scripts npm:@tailwindcss/cli@4.2.0 -i ./public/css/tailwind-input.css -o ./public/css/tailwind.css
.PHONY: watch-tailwind
watch-tailwind:
deno install --allow-scripts npm:tailwindcss@4.2.0 npm:@tailwindcss/cli@4.2.0
deno run --allow-env --allow-read --allow-sys --allow-ffi --vendor --unstable-detect-cjs --allow-write=public/css,/var/folders --allow-scripts npm:@tailwindcss/cli@4.2.0 -w -i ./public/css/tailwind-input.css -o ./public/css/tailwind.css
.PHONY: preview
preview:
deno task preview

View file

@ -2,7 +2,7 @@
[![](https://github.com/bewcloud/bewcloud/workflows/Run%20Tests/badge.svg)](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests) [![](https://github.com/bewcloud/bewcloud/workflows/Run%20Tests/badge.svg)](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests)
This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/). This is the [bewCloud app](https://bewcloud.com) built with [Deno](https://deno.land/) and deployed using [docker compose](https://docs.docker.com/compose/).
If you're looking for the desktop sync app, it's at [`bewcloud-desktop`](https://github.com/bewcloud/bewcloud-desktop). If you're looking for the desktop sync app, it's at [`bewcloud-desktop`](https://github.com/bewcloud/bewcloud-desktop).
@ -10,11 +10,9 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github
## Self-host it! ## Self-host it!
[![Buy managed cloud (1 year)](https://img.shields.io/badge/Buy%20managed%20cloud%20(1%20year)-51a4fb?style=for-the-badge)](https://buy.stripe.com/eVa01HgQk0Ap0eseVz) [![Buy managed cloud (1 year)](https://img.shields.io/badge/Buy%20managed%20cloud%20(1%20year)-51a4fb?style=for-the-badge)](https://payment-links.mollie.com/payment/AiEHa5EpD6wkZN5r6Vs3c)
[![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, start with these commands:
Or on your own machine, start with these commands:
```sh ```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) 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,14 +37,16 @@ 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. > `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.
If you're interested in building/contributing, check the [Development section below](#development). If you're interested in building/contributing (or just running the app locally), check the [Development section below](#development).
See the [Community Links](#community-links) section for alternative ways of running bewCloud yourself.
> [!IMPORTANT] > [!IMPORTANT]
> Even with signups disabled (`config.auth.allowSignups=false`), the first signup will work and become an admin. > Even with signups disabled (`config.auth.allowSignups=false`), the first signup will work and become an admin.
## Sponsors ## Sponsors
These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://donate.stripe.com/bIYeWBbw00Ape5iaFi) above $50 ($100 to show up on the website)! These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5) above $50 ($100 to show up on the website)!
<p align="center" width="100%"> <p align="center" width="100%">
<a href="https://nlnet.nl/project/bewCloud/" title="NLnet Foundation"> <a href="https://nlnet.nl/project/bewCloud/" title="NLnet Foundation">
@ -70,24 +70,25 @@ These are the amazing entities or individuals who are sponsoring this project fo
```sh ```sh
docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally
make migrate-db # runs any missing database migrations make migrate-db # runs any missing database migrations
make start # runs the app make start # runs the app in development mode (watches for CSS file changes and recompiles the CSS)
make format # formats the code make format # (optional) formats the code (if you're interested in contributing)
make test # runs tests make test # (optional) runs tests (if you're interested in contributing)
make build # (optional) generates CSS for production, if you've made changes
``` ```
### Other less-used commands ### Other less-used commands (mostly for development)
```sh ```sh
make preview # runs the app in production mode (serves the app from the built files)
make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";` 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/`. - Backend routes are defined at `routes.ts`
- Static files are defined at `static/`. - Publicly-available files are defined at `public/`
- Frontend-only components are defined at `components/`. - Pages are defined at `pages/`.
- Isomorphic components are defined at `islands/`. - JSX/TSX components are defined at `components/`.
- Cron jobs are defined at `crons/`. - Cron jobs are defined at `crons/`.
- Reusable bits of code are defined at `lib/`. - Reusable bits of code are defined at `lib/`.
- Database migrations are defined at `db-migrations/`. - Database migrations are defined at `db-migrations/`.
@ -96,24 +97,20 @@ make build # generates all static files for production deploy
Just push to the `main` branch. 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). [Check the FAQ](/FAQ.md) for answers to common questions, like private calendar and file sharing, or `.env`-based configuration.
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 does it look? ## How does it look?
[Check the website](https://bewcloud.com) for screenshots or [the YouTube channel](https://www.youtube.com/@bewCloud) for 1-minute demos. [Check the website](https://bewcloud.com) for screenshots or [the YouTube channel](https://www.youtube.com/@bewCloud) for 1-minute demos.
## Community Links
These are not officially endorsed, but are alternative ways of running bewCloud.
- [`bewcloud-nixos`](https://codeberg.org/ntninja/bewcloud-nixos/) by [@ntninja](https://codeberg.org/ntninja/) exposes bewCloud as an easy-to-use NixOS integration as an alternative to using Docker or running the app locally.
- For installation, please see the [README](https://codeberg.org/ntninja/bewcloud-nixos/src/branch/main/README.md).
- [`bewcloud-helm`](https://github.com/loboda4450/charts/tree/main/charts/bewcloud) by [@loboda4450](https://github.com/loboda4450/) exposes bewCloud as a Helm chart.
- For installation, please see the [README](https://github.com/loboda4450/charts/blob/main/charts/bewcloud/README.md)
- Supports automatic migrations, Radicale installation (as a dependency chart) and streamlined, fully yaml-based configuration.

56
babel.config.js Normal file
View file

@ -0,0 +1,56 @@
// Converts local TSX imports to JS imports, used in the components
const rewriteLocalTsxImports = () => {
const isLocal = (value) => typeof value === 'string' && (value.startsWith('./') || value.startsWith('../'));
const isComponent = (value) => typeof value === 'string' && value.startsWith('/components/');
const rewrite = (source) => {
if (source && isLocal(source.value) && source.value.endsWith('.tsx')) {
source.value = source.value.replace('.tsx', '.js');
}
if (source && isComponent(source.value) && source.value.endsWith('.tsx')) {
source.value = source.value.replace('/components/', '/public/components/').replace('.tsx', '.js');
}
};
return {
visitor: {
ImportDeclaration(path) {
rewrite(path.node.source);
},
ExportAllDeclaration(path) {
rewrite(path.node.source);
},
ExportNamedDeclaration(path) {
if (path.node.source) rewrite(path.node.source);
},
},
};
};
const presets = [
['@babel/preset-react'],
['@babel/preset-typescript', { jsxPragma: 'h' }],
];
const plugins = [
rewriteLocalTsxImports,
[
'@babel/plugin-transform-react-jsx',
{
'pragma': 'h',
'pragmaFrag': 'Fragment',
'jsxImportSource': 'preact',
},
],
];
export default {
sourceType: 'module',
targets: '> 0.5%, not dead',
presets,
plugins,
comments: false,
compact: false,
minified: false,
};

View file

@ -3,7 +3,7 @@ import { Config, PartialDeep } from './lib/types.ts';
/** Check the Config type for all the possible options and instructions. */ /** Check the Config type for all the possible options and instructions. */
const config: PartialDeep<Config> = { const config: PartialDeep<Config> = {
auth: { auth: {
baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback") baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (note authentication won't work without https:// except for localhost; SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback")
allowSignups: false, // If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin allowSignups: false, // If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin
enableEmailVerification: false, // If true, email verification will be required for signups (using SMTP settings below) enableEmailVerification: false, // If true, email verification will be required for signups (using SMTP settings below)
enableForeverSignup: true, // If true, all signups become active for 100 years enableForeverSignup: true, // If true, all signups become active for 100 years
@ -11,6 +11,7 @@ const config: PartialDeep<Config> = {
// allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies // allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies
// skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel // skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel
// enableSingleSignOn: false, // If true, single sign-on will be enabled // enableSingleSignOn: false, // If true, single sign-on will be enabled
// allowSignupsViaSingleSignOn: false, // If true, signups via single sign-on will be allowed, overriding allowSignups
// singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider // singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider
// singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider // singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider
// singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider // singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider
@ -18,9 +19,12 @@ const config: PartialDeep<Config> = {
// files: { // files: {
// rootPath: 'data-files', // rootPath: 'data-files',
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) // allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
// allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files
// maxUploadSizeInMegabytes: 100, // The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints.
// }, // },
// core: { // core: {
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // dashboard and files cannot be disabled // enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required.
// maxRequestSizeInMegabytes: 12, // The maximum request size in megabytes.
// }, // },
// visuals: { // visuals: {
// title: 'My own cloud', // title: 'My own cloud',
@ -31,6 +35,8 @@ const config: PartialDeep<Config> = {
// from: 'help@bewcloud.com', // from: 'help@bewcloud.com',
// host: 'localhost', // host: 'localhost',
// port: 465, // port: 465,
// tlsMode: 'auto', // "auto" means "immediate" on port 465, "starttls" otherwise.
// tlsVerify: true, // Whether to verify the TLS certificate. If a string is used the hostname will be verified using that name.
// }, // },
// contacts: { // contacts: {
// enableCardDavServer: true, // enableCardDavServer: true,

View file

@ -1,10 +1,9 @@
import { Head } from 'fresh/runtime.ts';
import { OptionalApp, User } from '/lib/types.ts'; import { OptionalApp, User } from '/lib/types.ts';
import { capitalizeWord } from '/public/ts/utils/misc.ts';
interface Data { interface Data {
route: string; route: string;
user?: User; user?: User | null;
enabledApps: OptionalApp[]; enabledApps: OptionalApp[];
} }
@ -23,56 +22,14 @@ export default function Header({ route, user, enabledApps }: Data) {
const iconWidthAndHeightInPixels = 20; const iconWidthAndHeightInPixels = 20;
const potentialMenuItems: (MenuItem | null)[] = [ const potentialMenuItems: (MenuItem | null)[] = enabledApps.map((app) => ({
{ url: `/${app}`,
url: '/dashboard', label: capitalizeWord(app),
label: 'Dashboard', }));
},
enabledApps.includes('news')
? {
url: '/news',
label: 'News',
}
: null,
{
url: '/files',
label: 'Files',
},
enabledApps.includes('notes')
? {
url: '/notes',
label: 'Notes',
}
: null,
enabledApps.includes('photos')
? {
url: '/photos',
label: 'Photos',
}
: null,
enabledApps.includes('expenses')
? {
url: '/expenses',
label: 'Expenses',
}
: null,
enabledApps.includes('contacts')
? {
url: '/contacts',
label: 'Contacts',
}
: null,
enabledApps.includes('calendar')
? {
url: '/calendar',
label: 'Calendar',
}
: null,
];
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[]; const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
if (user) { if (user && !route.startsWith('/file-share')) {
const activeMenu = menuItems.find((menu) => route.startsWith(menu.url)); const activeMenu = menuItems.find((menu) => route.startsWith(menu.url));
let pageLabel = activeMenu?.label || '404 - Page not found'; let pageLabel = activeMenu?.label || '404 - Page not found';
@ -99,16 +56,17 @@ export default function Header({ route, user, enabledApps }: Data) {
return ( return (
<> <>
<Head>
<title>{pageLabel} - bewCloud</title>
</Head>
<nav class='bg-slate-950'> <nav class='bg-slate-950'>
<div class='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'> <div class='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'>
<div class='flex h-16 items-center justify-between'> <div class='flex h-16 items-center justify-between'>
<div class='flex items-center'> <div class='flex items-center'>
<div class='flex-shrink-0'> <div class='shrink-0'>
<a href='/'> <a href='/'>
<img class='h-12 w-12 drop-shadow-md' src='/images/logomark.svg' alt='a stylized blue cloud' /> <img
class='h-12 w-12 drop-shadow-md'
src='/public/images/logomark.svg'
alt='a stylized blue cloud'
/>
</a> </a>
</div> </div>
<div class='hidden md:block'> <div class='hidden md:block'>
@ -116,7 +74,7 @@ export default function Header({ route, user, enabledApps }: Data) {
{menuItems.map((menu) => ( {menuItems.map((menu) => (
<a href={menu.url} class={route.startsWith(menu.url) ? activeClass : defaultClass}> <a href={menu.url} class={route.startsWith(menu.url) ? activeClass : defaultClass}>
<img <img
src={`/images${menu.url}${'.svg'}`} src={`/public/images${menu.url}${'.svg'}`}
alt={menu.label} alt={menu.label}
title={menu.label} title={menu.label}
width={iconWidthAndHeightInPixels} width={iconWidthAndHeightInPixels}
@ -137,7 +95,7 @@ export default function Header({ route, user, enabledApps }: Data) {
class={route.startsWith('/settings') ? activeClass : defaultClass} class={route.startsWith('/settings') ? activeClass : defaultClass}
> >
<img <img
src='/images/settings.svg' src='/public/images/settings.svg'
alt='Settings' alt='Settings'
title='Settings' title='Settings'
width={iconWidthAndHeightInPixels} width={iconWidthAndHeightInPixels}
@ -150,7 +108,7 @@ export default function Header({ route, user, enabledApps }: Data) {
class={defaultClass} class={defaultClass}
> >
<img <img
src='/images/logout.svg' src='/public/images/logout.svg'
alt='Logout' alt='Logout'
title='Logout' title='Logout'
width={iconWidthAndHeightInPixels} width={iconWidthAndHeightInPixels}
@ -186,11 +144,11 @@ export default function Header({ route, user, enabledApps }: Data) {
} }
return ( return (
<header class='px-4 pt-8 pb-2 max-w-screen-md mx-auto flex flex-col items-center justify-center'> <header class='px-4 pt-8 pb-2 max-w-3xl mx-auto flex flex-col items-center justify-center'>
<a href='/'> <a href='/'>
<img <img
class='mt-6 mb-2 drop-shadow-md' class='mt-6 mb-2 drop-shadow-md'
src='/images/logo-white.svg' src='/public/images/logo-white.svg'
width='250' width='250'
height='50' height='50'
alt='the bewCloud logo: a stylized logo' alt='the bewCloud logo: a stylized logo'

25
components/Loading.ts Normal file
View file

@ -0,0 +1,25 @@
import { html } from '/public/ts/utils/misc.ts';
export default function Loading() {
return html`
<section role="status" id="loading" class="flex justify-center items-center">
<svg
aria-hidden="true"
class="w-8 h-8 text-slate-700 animate-spin fill-sky-400"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</section>
`;
}

295
components/Settings.ts Normal file
View file

@ -0,0 +1,295 @@
import { FormField, generateFieldHtml, getFormDataField } from '/public/ts/utils/form.ts';
import { convertObjectToFormData, currencyMap, escapeHtml, html } from '/public/ts/utils/misc.ts';
import { SupportedCurrencySymbol, User } from '/lib/types.ts';
import { getEnabledMultiFactorAuthMethodsFromUser } from '/public/ts/utils/multi-factor-auth.ts';
import { getTimeZones } from '/public/ts/utils/calendar.ts';
import Loading from '/components/Loading.ts';
interface SettingsProps {
formData: Record<string, any>;
error?: {
title: string;
message: string;
};
notice?: {
title: string;
message: string;
};
currency?: SupportedCurrencySymbol;
timezoneId?: string;
isExpensesAppEnabled: boolean;
isMultiFactorAuthEnabled: boolean;
isCalendarAppEnabled: boolean;
helpEmail: string;
user: {
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
};
}
export type Action =
| 'change-email'
| 'verify-change-email'
| 'change-password'
| 'change-dav-password'
| 'delete-account'
| 'change-currency'
| 'change-timezone';
export const actionWords = new Map<Action, string>([
['change-email', 'change email'],
['verify-change-email', 'change email'],
['change-password', 'change password'],
['change-dav-password', 'change WebDav password'],
['delete-account', 'delete account'],
['change-currency', 'change currency'],
['change-timezone', 'change timezone'],
]);
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
const fields: FormField[] = [
{
name: 'action',
label: '',
type: 'hidden',
value: action,
overrideValue: action,
required: true,
readOnly: true,
},
];
if (action === 'change-email') {
fields.push({
name: 'email',
label: 'Email',
type: 'email',
placeholder: 'jane.doe@example.com',
value: getFormDataField(formData, 'email'),
required: true,
});
} else if (action === 'verify-change-email') {
fields.push({
name: 'email',
label: 'Email',
type: 'email',
placeholder: 'jane.doe@example.com',
value: getFormDataField(formData, 'email'),
required: true,
}, {
name: 'verification-code',
label: 'Verification Code',
description: `The verification code to validate your new email.`,
type: 'text',
placeholder: '000000',
required: true,
});
} else if (action === 'change-password') {
fields.push({
name: 'current-password',
label: 'Current Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
required: true,
}, {
name: 'new-password',
label: 'New Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
required: true,
});
} else if (action === 'change-dav-password') {
fields.push({
name: 'new-dav-password',
label: 'New WebDav Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
required: true,
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
});
} else if (action === 'delete-account') {
fields.push({
name: 'current-password',
label: 'Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
description: 'You need to input your password in order to delete your account.',
required: true,
});
} else if (action === 'change-currency') {
fields.push({
name: 'currency',
label: 'Currency',
type: 'select',
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
value: currencySymbol,
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
})),
value: getFormDataField(formData, 'currency') || currency,
required: true,
});
} else if (action === 'change-timezone') {
const timezones = getTimeZones();
fields.push({
name: 'timezone',
label: 'Timezone',
type: 'select',
options: timezones.map((timezone) => ({
value: timezone.id,
label: timezone.label,
})),
value: getFormDataField(formData, 'timezone') || timezoneId,
required: true,
});
}
return fields;
}
export default function Settings(
{
formData: formDataObject,
error,
notice,
currency,
timezoneId,
isExpensesAppEnabled,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
helpEmail,
user,
}: SettingsProps,
): string {
const formData = convertObjectToFormData(formDataObject);
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
return html`
<section class="mx-auto max-w-7xl my-8">
${error
? html`
<section class="notification-error">
<h3>${escapeHtml(error.title)}</h3>
<p>${escapeHtml(error.message)}</p>
</section>
`
: ''} ${notice
? html`
<section class="notification-success">
<h3>${escapeHtml(notice.title)}</h3>
<p>${escapeHtml(notice.message)}</p>
</section>
`
: ''}
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your email</h2>
<form method="POST" class="mb-12">
${formFields('change-email', formData).map((field) => generateFieldHtml(field, formData)).join('')}
<section class="flex justify-end mt-8 mb-4">
<button class="button-secondary" type="submit">Change email</button>
</section>
</form>
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your password</h2>
<form method="POST" class="mb-12">
${formFields('change-password', formData).map((field) => generateFieldHtml(field, formData)).join('')}
<section class="flex justify-end mt-8 mb-4">
<button class="button-secondary" type="submit">Change password</button>
</section>
</form>
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your WebDav password</h2>
<form method="POST" class="mb-12">
${formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData)).join('')}
<section class="flex justify-end mt-8 mb-4">
<button class="button-secondary" type="submit">Change WebDav password</button>
</section>
</form>
${isExpensesAppEnabled
? html`
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your currency</h2>
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
</p>
<form method="POST" class="mb-12">
${formFields('change-currency', formData, currency, timezoneId).map((field) =>
generateFieldHtml(field, formData)
).join('')}
<section class="flex justify-end mt-8 mb-4">
<button class="button-secondary" type="submit">Change currency</button>
</section>
</form>
`
: ''} ${isCalendarAppEnabled
? html`
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your timezone</h2>
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
This is only used in the calendar app.
</p>
<form method="POST" class="mb-12">
${formFields('change-timezone', formData, currency, timezoneId).map((field) =>
generateFieldHtml(field, formData)
).join('')}
<section class="flex justify-end mt-8 mb-4">
<button class="button-secondary" type="submit">Change timezone</button>
</section>
</form>
`
: ''} ${isMultiFactorAuthEnabled
? html`
<section id="multi-factor-auth-settings">
${Loading()}
</section>
`
: ''}
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Delete your account</h2>
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
Deleting your account is instant and deletes all your data. ${helpEmail !== ''
? html`
If you need help, please <a href="${`mailto:${helpEmail}`}">reach out</a>.
`
: ''}
</p>
<form method="POST" class="mb-12">
${formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData)).join('')}
<section class="flex justify-end mt-8 mb-4">
<button class="button-danger" type="submit">Delete account</button>
</section>
</form>
</section>
<script type="module">
import { h, render } from 'preact';
// Imported files need some preact globals to work
window.h = h;
import MultiFactorAuthSettings from '/public/components/auth/MultiFactorAuthSettings.js';
const multiFactorAuthSettingsElement = document.getElementById('multi-factor-auth-settings');
if (multiFactorAuthSettingsElement) {
const multiFactorAuthSettingsApp = h(MultiFactorAuthSettings, {
methods: ${JSON.stringify(multiFactorAuthMethods.map((method) => ({
type: method.type,
id: method.id,
name: method.name,
enabled: method.enabled,
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
})))},
});
render(multiFactorAuthSettingsApp, multiFactorAuthSettingsElement);
document.getElementById('loading')?.remove();
}
</script>
`;
}

View file

@ -2,30 +2,21 @@ import { useSignal } from '@preact/signals';
import { startRegistration } from '@simplewebauthn/browser'; import { startRegistration } from '@simplewebauthn/browser';
import { MultiFactorAuthMethodType } from '/lib/types.ts'; import { MultiFactorAuthMethodType } from '/lib/types.ts';
import { import { ResponseBody as PasskeySetupBeginResponseBody } from '/pages/api/auth/multi-factor/passkey/setup-begin.ts';
RequestBody as PasskeySetupBeginRequestBody,
ResponseBody as PasskeySetupBeginResponseBody,
} from '/routes/api/auth/multi-factor/passkey/setup-begin.ts';
import { import {
RequestBody as PasskeySetupCompleteRequestBody, RequestBody as PasskeySetupCompleteRequestBody,
ResponseBody as PasskeySetupCompleteResponseBody, ResponseBody as PasskeySetupCompleteResponseBody,
} from '/routes/api/auth/multi-factor/passkey/setup-complete.ts'; } from '/pages/api/auth/multi-factor/passkey/setup-complete.ts';
import { import { ResponseBody as TOTPSetupResponseBody } from '/pages/api/auth/multi-factor/totp/setup.ts';
RequestBody as TOTPSetupRequestBody, import { ResponseBody as EmailSetupResponseBody } from '/pages/api/auth/multi-factor/email/setup.ts';
ResponseBody as TOTPSetupResponseBody,
} from '/routes/api/auth/multi-factor/totp/setup.ts';
import {
RequestBody as EmailSetupRequestBody,
ResponseBody as EmailSetupResponseBody,
} from '/routes/api/auth/multi-factor/email/setup.ts';
import { import {
RequestBody as MultiFactorAuthEnableRequestBody, RequestBody as MultiFactorAuthEnableRequestBody,
ResponseBody as MultiFactorAuthEnableResponseBody, ResponseBody as MultiFactorAuthEnableResponseBody,
} from '/routes/api/auth/multi-factor/enable.ts'; } from '/pages/api/auth/multi-factor/enable.ts';
import { import {
RequestBody as MultiFactorAuthDisableRequestBody, RequestBody as MultiFactorAuthDisableRequestBody,
ResponseBody as MultiFactorAuthDisableResponseBody, ResponseBody as MultiFactorAuthDisableResponseBody,
} from '/routes/api/auth/multi-factor/disable.ts'; } from '/pages/api/auth/multi-factor/disable.ts';
interface MultiFactorAuthMethod { interface MultiFactorAuthMethod {
type: MultiFactorAuthMethodType; type: MultiFactorAuthMethodType;
@ -84,11 +75,9 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
const hasMultiFactorAuthEnabled = enabledMethods.length > 0; const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
const setupPasskey = async () => { const setupPasskey = async () => {
const beginRequestBody: PasskeySetupBeginRequestBody = {};
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', { const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
method: 'POST', method: 'POST',
body: JSON.stringify(beginRequestBody), body: JSON.stringify({}),
}); });
if (!beginResponse.ok) { if (!beginResponse.ok) {
@ -135,11 +124,9 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
}; };
const setupTOTP = async () => { const setupTOTP = async () => {
const requestBody: TOTPSetupRequestBody = {};
const response = await fetch('/api/auth/multi-factor/totp/setup', { const response = await fetch('/api/auth/multi-factor/totp/setup', {
method: 'POST', method: 'POST',
body: JSON.stringify(requestBody), body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
@ -164,11 +151,9 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
}; };
const setupEmail = async () => { const setupEmail = async () => {
const requestBody: EmailSetupRequestBody = {};
const response = await fetch('/api/auth/multi-factor/email/setup', { const response = await fetch('/api/auth/multi-factor/email/setup', {
method: 'POST', method: 'POST',
body: JSON.stringify(requestBody), body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
@ -321,11 +306,11 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
return ( return (
<section class='mb-16'> <section class='mb-16'>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'> <h2 class='text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96'>
Multi-Factor Authentication (MFA) Multi-Factor Authentication (MFA)
</h2> </h2>
<section class='px-4 max-w-screen-md mx-auto lg:min-w-96'> <section class='px-4 max-w-3xl mx-auto lg:min-w-96'>
{error.value {error.value
? ( ? (
<section class='notification-error mb-4'> <section class='notification-error mb-4'>
@ -391,8 +376,8 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
<p class='mb-4'> <p class='mb-4'>
1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.): 1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.):
</p> </p>
<section class='flex justify-center mb-4'> <section class='flex justify-center mb-4 max-w-sm mx-auto'>
<img src={setupData.value.qrCodeUrl} alt='TOTP QR Code' class='border' /> <img src={setupData.value.qrCodeUrl} alt='TOTP QR Code' class='border-8 border-white' />
</section> </section>
<p class='text-sm text-gray-400 mb-4'> <p class='text-sm text-gray-400 mb-4'>
Or manually enter this secret:{' '} Or manually enter this secret:{' '}

View file

@ -1,5 +1,5 @@
import { MultiFactorAuthMethodType } from '/lib/types.ts'; import { MultiFactorAuthMethodType } from '/lib/types.ts';
import PasswordlessPasskeyLogin from '/islands/auth/PasswordlessPasskeyLogin.tsx'; import PasswordlessPasskeyLogin from '/components/auth/PasswordlessPasskeyLogin.tsx';
interface MultiFactorAuthVerifyFormProps { interface MultiFactorAuthVerifyFormProps {
email: string; email: string;
@ -16,7 +16,7 @@ export default function MultiFactorAuthVerifyForm(
const hasEmail = availableMethods.includes('email'); const hasEmail = availableMethods.includes('email');
return ( return (
<section class='max-w-md w-full mb-12'> <section class='max-w-md w-full mb-12 mx-auto'>
<section class='mb-6'> <section class='mb-6'>
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'> <h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
Multi-Factor Authentication Multi-Factor Authentication

View file

@ -0,0 +1,29 @@
export interface PasswordlessPasskeyLoginProps {
email?: string;
redirectUrl?: string;
}
export default function PasswordlessPasskeyLogin({ email, redirectUrl }: PasswordlessPasskeyLoginProps) {
return (
<>
<section class='space-y-4'>
<section class='flex justify-center mt-2 mb-4'>
<button
id='passwordless-passkey-login-button'
type='button'
class='button-secondary'
data-email={email}
data-redirect-url={redirectUrl}
>
Login with Passkey
</button>
</section>
<section class='notification-error hidden' id='passwordless-passkey-login-error'></section>
</section>
<script src='/public/js/simplewebauthn.js'></script>
<script type='module' src='/public/ts/passwordless-passkey-login.ts'></script>
</>
);
}

View file

@ -52,7 +52,7 @@ export default function AddEventModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>
@ -79,7 +79,7 @@ export default function AddEventModal(
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label> <label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
<section class='flex items-center justify-between'> <section class='flex items-center justify-between'>
<select <select
class='input-field mr-2 !w-5/6' class='input-field mr-2 w-5/6!'
name='event_calendar' name='event_calendar'
id='event_calendar' id='event_calendar'
value={newEvent.value?.calendarId || ''} value={newEvent.value?.calendarId || ''}

View file

@ -1,5 +1,5 @@
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { getCalendarEventStyle } from '/lib/utils/calendar.ts'; import { getCalendarEventStyle } from '/public/ts/utils/calendar.ts';
interface CalendarViewDayProps { interface CalendarViewDayProps {
startDate: Date; startDate: Date;

View file

@ -1,5 +1,5 @@
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { getCalendarEventStyle, getWeeksForMonth } from '/lib/utils/calendar.ts'; import { getCalendarEventStyle, getWeeksForMonth } from '/public/ts/utils/calendar.ts';
interface CalendarViewWeekProps { interface CalendarViewWeekProps {
startDate: Date; startDate: Date;

View file

@ -1,5 +1,5 @@
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { getCalendarEventStyle, getDaysForWeek } from '/lib/utils/calendar.ts'; import { getCalendarEventStyle, getDaysForWeek } from '/public/ts/utils/calendar.ts';
interface CalendarViewWeekProps { interface CalendarViewWeekProps {
startDate: Date; startDate: Date;

View file

@ -1,10 +1,10 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Calendar } from '/lib/models/calendar.ts'; import { Calendar } from '/lib/models/calendar.ts';
import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/lib/utils/calendar.ts'; import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/public/ts/utils/calendar.ts';
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/calendar/add.ts';
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx'; import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/pages/api/calendar/update.ts';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/calendar/delete.ts';
interface CalendarsProps { interface CalendarsProps {
initialCalendars: Calendar[]; initialCalendars: Calendar[];
@ -150,7 +150,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
onClick={() => onClickAddCalendar()} onClick={() => onClickAddCalendar()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new calendar' alt='Add new calendar'
class={`white ${isAdding.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -194,7 +194,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
onClick={() => onClickDeleteCalendar(calendar.uid!)} onClick={() => onClickDeleteCalendar(calendar.uid!)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={24} width={24}
height={24} height={24}
@ -225,14 +225,14 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}
{isSaving.value {isSaving.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</> </>
) )
: null} : null}
@ -241,9 +241,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
</section> </section>
<section <section
class={`fixed ${ class={`fixed ${openCalendar.value ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
openCalendar.value ? 'block' : 'hidden'
} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
> >
</section> </section>
@ -271,7 +269,7 @@ export default function Calendars({ initialCalendars }: CalendarsProps) {
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label> <label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
<section class='flex items-center justify-between'> <section class='flex items-center justify-between'>
<select <select
class='input-field mr-2 !w-5/6' class='input-field mr-2 w-5/6!'
name='calendar_color' name='calendar_color'
id='calendar_color' id='calendar_color'
value={openCalendar.value?.calendarColor || ''} value={openCalendar.value?.calendarColor || ''}

View file

@ -26,7 +26,7 @@ export default function ImportEventsModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>
@ -41,7 +41,7 @@ export default function ImportEventsModal(
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label> <label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
<section class='flex items-center justify-between'> <section class='flex items-center justify-between'>
<select <select
class='input-field mr-2 !w-5/6' class='input-field mr-2 w-5/6!'
name='event_calendar' name='event_calendar'
id='event_calendar' id='event_calendar'
value={newCalendarId.value || ''} value={newCalendarId.value || ''}

View file

@ -1,18 +1,18 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { capitalizeWord } from '/lib/utils/misc.ts'; import { capitalizeWord } from '/public/ts/utils/misc.ts';
import { generateVCalendar } from '/lib/utils/calendar.ts'; import { generateVCalendar } from '/public/ts/utils/calendar.ts';
import { import {
RequestBody as ExportRequestBody, RequestBody as ExportRequestBody,
ResponseBody as ExportResponseBody, ResponseBody as ExportResponseBody,
} from '/routes/api/calendar/export-events.tsx'; } from '/pages/api/calendar/export-events.ts';
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/calendar/add-event.ts';
import { import {
RequestBody as DeleteRequestBody, RequestBody as DeleteRequestBody,
ResponseBody as DeleteResponseBody, ResponseBody as DeleteResponseBody,
} from '/routes/api/calendar/delete-event.tsx'; } from '/pages/api/calendar/delete-event.ts';
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/calendar/import.tsx'; import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/calendar/import.ts';
import CalendarViewDay from './CalendarViewDay.tsx'; import CalendarViewDay from './CalendarViewDay.tsx';
import CalendarViewWeek from './CalendarViewWeek.tsx'; import CalendarViewWeek from './CalendarViewWeek.tsx';
import CalendarViewMonth from './CalendarViewMonth.tsx'; import CalendarViewMonth from './CalendarViewMonth.tsx';
@ -32,8 +32,7 @@ interface MainCalendarProps {
} }
export default function MainCalendar( export default function MainCalendar(
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }: { initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId }: MainCalendarProps,
MainCalendarProps,
) { ) {
const isAdding = useSignal<boolean>(false); const isAdding = useSignal<boolean>(false);
const isDeleting = useSignal<boolean>(false); const isDeleting = useSignal<boolean>(false);
@ -449,7 +448,7 @@ export default function MainCalendar(
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!isViewOptionsDropdownOpen.value ? 'hidden' : '' !isViewOptionsDropdownOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -510,7 +509,7 @@ export default function MainCalendar(
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!isImportExportOptionsDropdownOpen.value ? 'hidden' : '' !isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -543,7 +542,7 @@ export default function MainCalendar(
onClick={() => onClickAddEvent()} onClick={() => onClickAddEvent()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new event' alt='Add new event'
class={`white ${isAdding.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -597,21 +596,21 @@ export default function MainCalendar(
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}
{isExporting.value {isExporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
</> </>
) )
: null} : null}
{isImporting.value {isImporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
</> </>
) )
: null} : null}

View file

@ -2,8 +2,9 @@ import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx'; import { RequestBody, ResponseBody } from '/pages/api/calendar/search-events.ts';
import { getColorAsHex } from '/lib/utils/calendar.ts'; import { getColorAsHex } from '/public/ts/utils/calendar.ts';
interface SearchEventsProps { interface SearchEventsProps {
calendars: Calendar[]; calendars: Calendar[];
onClickOpenEvent: (calendarEvent: CalendarEvent) => void; onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
@ -105,12 +106,12 @@ export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEven
onFocus={() => onFocus()} onFocus={() => onFocus()}
onBlur={() => onBlur()} onBlur={() => onBlur()}
/> />
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null} {isSearching.value ? <img src='/public/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-xs'>
<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-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none`}
role='menu' role='menu'
aria-orientation='vertical' aria-orientation='vertical'
aria-labelledby='view-button' aria-labelledby='view-button'

View file

@ -1,12 +1,12 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts'; import { capitalizeWord, convertObjectToFormData } from '/public/ts/utils/misc.ts';
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; import { FormField, generateFieldHtml } from '/public/ts/utils/form.ts';
import { import {
RequestBody as DeleteRequestBody, RequestBody as DeleteRequestBody,
ResponseBody as DeleteResponseBody, ResponseBody as DeleteResponseBody,
} from '/routes/api/calendar/delete-event.tsx'; } from '/pages/api/calendar/delete-event.ts';
interface ViewCalendarEventProps { interface ViewCalendarEventProps {
initialCalendarEvent: CalendarEvent; initialCalendarEvent: CalendarEvent;
@ -183,7 +183,7 @@ export default function ViewCalendarEvent(
onClick={() => onClickDeleteEvent()} onClick={() => onClickDeleteEvent()}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
alt='Delete event' alt='Delete event'
class={`white ${isDeleting.value ? 'animate-spin' : ''}`} class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -212,7 +212,13 @@ export default function ViewCalendarEvent(
: null} : null}
<form method='POST' class='mb-12'> <form method='POST' class='mb-12'>
{formFields(calendarEvent.peek(), calendars, 'ui').map((field) => generateFieldHtml(field, formData))} <div
dangerouslySetInnerHTML={{
__html: formFields(calendarEvent.peek(), calendars, 'ui').map((field) =>
generateFieldHtml(field, formData)
).join(''),
}}
/>
<section class='flex justify-end items-center mt-8 mb-4'> <section class='flex justify-end items-center mt-8 mb-4'>
{calendarEvent.peek().isRecurring {calendarEvent.peek().isRecurring
@ -232,12 +238,18 @@ export default function ViewCalendarEvent(
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'> <summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
Edit Raw ICS{' '} Edit Raw ICS{' '}
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'> <span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' /> <img src='/public/images/right.svg' alt='Expand' width={16} height={16} class='white' />
</span> </span>
</summary> </summary>
<form method='POST' class='mb-12'> <form method='POST' class='mb-12'>
{formFields(calendarEvent.peek(), calendars, 'raw').map((field) => generateFieldHtml(field, formData))} <div
dangerouslySetInnerHTML={{
__html: formFields(calendarEvent.peek(), calendars, 'raw').map((field) =>
generateFieldHtml(field, formData)
).join(''),
}}
/>
<section class='flex justify-end mt-8 mb-4'> <section class='flex justify-end mt-8 mb-4'>
<button class='button' type='submit'>Update ICS</button> <button class='button' type='submit'>Update ICS</button>
@ -251,7 +263,7 @@ export default function ViewCalendarEvent(
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}

View file

@ -1,5 +1,5 @@
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { convertRRuleToWords } from '/lib/utils/calendar.ts'; import { convertRRuleToWords } from '/public/ts/utils/calendar.ts';
interface ViewEventModalProps { interface ViewEventModalProps {
isOpen: boolean; isOpen: boolean;
@ -33,7 +33,7 @@ export default function ViewEventModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>
@ -86,7 +86,7 @@ export default function ViewEventModal(
? ( ? (
<section class='py-5 my-0 border-b border-slate-500'> <section class='py-5 my-0 border-b border-slate-500'>
<a <a
href={`https://maps.google.com/maps?q=${encodeURIComponent(calendarEvent.location)}`} href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(calendarEvent.location)}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
> >

View file

@ -1,18 +1,18 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { AddressBook, Contact } from '/lib/models/contacts.ts'; import { AddressBook, Contact } from '/lib/models/contacts.ts';
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx'; import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/pages/api/contacts/get.ts';
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/contacts/add.ts';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/contacts/delete.ts';
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx'; import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/contacts/import.ts';
import { import {
RequestBody as AddAddressBookRequestBody, RequestBody as AddAddressBookRequestBody,
ResponseBody as AddAddressBookResponseBody, ResponseBody as AddAddressBookResponseBody,
} from '/routes/api/contacts/add-addressbook.tsx'; } from '/pages/api/contacts/add-addressbook.ts';
import { import {
RequestBody as DeleteAddressBookRequestBody, RequestBody as DeleteAddressBookRequestBody,
ResponseBody as DeleteAddressBookResponseBody, ResponseBody as DeleteAddressBookResponseBody,
} from '/routes/api/contacts/delete-addressbook.tsx'; } from '/pages/api/contacts/delete-addressbook.ts';
interface ContactsProps { interface ContactsProps {
initialAddressBookId: string; initialAddressBookId: string;
@ -359,7 +359,7 @@ export default function Contacts(
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right divide-y divide-slate-600 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-44 origin-top-right divide-y divide-slate-600 rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!isAddressBooksDropdownOpen.value ? 'hidden' : '' !isAddressBooksDropdownOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -424,7 +424,7 @@ export default function Contacts(
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!isOptionsDropdownOpen.value ? 'hidden' : '' !isOptionsDropdownOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -457,7 +457,7 @@ export default function Contacts(
onClick={() => onClickAddContact()} onClick={() => onClickAddContact()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new contact' alt='Add new contact'
class={`white ${isAdding.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -493,7 +493,7 @@ export default function Contacts(
onClick={() => onClickDeleteContact(contact.uid!)} onClick={() => onClickDeleteContact(contact.uid!)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={24} width={24}
height={24} height={24}
@ -524,21 +524,21 @@ export default function Contacts(
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}
{isExporting.value {isExporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
</> </>
) )
: null} : null}
{isImporting.value {isImporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
</> </>
) )
: null} : null}

View file

@ -1,9 +1,9 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Contact } from '/lib/models/contacts.ts'; import { Contact } from '/lib/models/contacts.ts';
import { convertObjectToFormData } from '/lib/utils/misc.ts'; import { convertObjectToFormData } from '/public/ts/utils/misc.ts';
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; import { FormField, generateFieldHtml } from '/public/ts/utils/form.ts';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/contacts/delete.ts';
interface ViewContactProps { interface ViewContactProps {
addressBookId: string; addressBookId: string;
@ -131,7 +131,7 @@ export default function ViewContact(
onClick={() => onClickDeleteContact()} onClick={() => onClickDeleteContact()}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
alt='Delete contact' alt='Delete contact'
class={`white ${isDeleting.value ? 'animate-spin' : ''}`} class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -160,7 +160,11 @@ export default function ViewContact(
: null} : null}
<form method='POST' class='mb-12'> <form method='POST' class='mb-12'>
{formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData))} <div
dangerouslySetInnerHTML={{
__html: formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData)).join(''),
}}
/>
<section class='flex justify-end mt-8 mb-4'> <section class='flex justify-end mt-8 mb-4'>
<button class='button' type='submit'>Update contact</button> <button class='button' type='submit'>Update contact</button>
@ -173,12 +177,16 @@ export default function ViewContact(
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'> <summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
Edit Raw vCard{' '} Edit Raw vCard{' '}
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'> <span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' /> <img src='/public/images/right.svg' alt='Expand' width={16} height={16} class='white' />
</span> </span>
</summary> </summary>
<form method='POST' class='mb-12'> <form method='POST' class='mb-12'>
{formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData))} <div
dangerouslySetInnerHTML={{
__html: formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData)).join(''),
}}
/>
<section class='flex justify-end mt-8 mb-4'> <section class='flex justify-end mt-8 mb-4'>
<button class='button' type='submit'>Update vCard</button> <button class='button' type='submit'>Update vCard</button>
@ -192,7 +200,7 @@ export default function ViewContact(
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}

View file

@ -2,8 +2,8 @@ import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { DashboardLink } from '/lib/types.ts'; import { DashboardLink } from '/lib/types.ts';
import { validateUrl } from '/lib/utils/misc.ts'; import { validateUrl } from '/public/ts/utils/misc.ts';
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-links.tsx'; import { RequestBody, ResponseBody } from '/pages/api/dashboard/save-links.ts';
interface LinksProps { interface LinksProps {
initialLinks: DashboardLink[]; initialLinks: DashboardLink[];
@ -118,13 +118,13 @@ export default function Links({ initialLinks }: LinksProps) {
<section class='flex flex-row items-center justify-end mb-4'> <section class='flex flex-row items-center justify-end mb-4'>
<section class='flex items-center'> <section class='flex items-center'>
<button <button
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2' class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2 cursor-pointer'
type='button' type='button'
title='Add new link' title='Add new link'
onClick={() => onClickAddLink()} onClick={() => onClickAddLink()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new link' alt='Add new link'
class={`white`} class={`white`}
width={20} width={20}
@ -148,7 +148,7 @@ export default function Links({ initialLinks }: LinksProps) {
onClick={() => onClickDeleteLink(index)} onClick={() => onClickDeleteLink(index)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={24} width={24}
height={24} height={24}
@ -163,7 +163,7 @@ export default function Links({ initialLinks }: LinksProps) {
onClick={() => onClickMoveLeftLink(index)} onClick={() => onClickMoveLeftLink(index)}
> >
<img <img
src='/images/left-circle.svg' src='/public/images/left-circle.svg'
class='gray' class='gray'
width={24} width={24}
height={24} height={24}
@ -185,14 +185,14 @@ export default function Links({ initialLinks }: LinksProps) {
{isSaving.value {isSaving.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</> </>
) )
: null} : null}
{hasSaved.value {hasSaved.value
? ( ? (
<> <>
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved! <img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
</> </>
) )
: null} : null}

View file

@ -1,7 +1,7 @@
import { useSignal, useSignalEffect } from '@preact/signals'; import { useSignal, useSignalEffect } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-notes.tsx'; import { RequestBody, ResponseBody } from '/pages/api/dashboard/save-notes.ts';
interface NotesProps { interface NotesProps {
initialNotes: string; initialNotes: string;
@ -85,14 +85,14 @@ export default function Notes({ initialNotes }: NotesProps) {
{isSaving.value {isSaving.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</> </>
) )
: null} : null}
{hasSaved.value {hasSaved.value
? ( ? (
<> <>
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved! <img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
</> </>
) )
: null} : null}

View file

@ -2,7 +2,7 @@ import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Budget } from '/lib/types.ts'; import { Budget } from '/lib/types.ts';
import { formatInputToNumber } from '/lib/utils/misc.ts'; import { formatInputToNumber } from '/public/ts/utils/misc.ts';
interface BudgetModalProps { interface BudgetModalProps {
isOpen: boolean; isOpen: boolean;
@ -41,7 +41,7 @@ export default function BudgetModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>

View file

@ -2,12 +2,12 @@ import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Budget, Expense } from '/lib/types.ts'; import { Budget, Expense } from '/lib/types.ts';
import { formatInputToNumber } from '/lib/utils/misc.ts'; import { formatInputToNumber } from '/public/ts/utils/misc.ts';
import { import {
RequestBody as SuggestionsRequestBody, RequestBody as SuggestionsRequestBody,
ResponseBody as SuggestionsResponse, ResponseBody as SuggestionsResponse,
} from '/routes/api/expenses/auto-complete.tsx'; } from '/pages/api/expenses/auto-complete.ts';
interface ExpenseModalProps { interface ExpenseModalProps {
isOpen: boolean; isOpen: boolean;
@ -98,7 +98,7 @@ export default function ExpenseModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>
@ -191,7 +191,6 @@ export default function ExpenseModal(
onChange={(event) => { onChange={(event) => {
newExpenseBudget.value = event.currentTarget.value; newExpenseBudget.value = event.currentTarget.value;
}} }}
placeholder='Misc'
> >
{sortedBudgetNames.map((budget) => ( {sortedBudgetNames.map((budget) => (
<option value={budget} selected={newExpenseBudget.value === budget}>{budget}</option> <option value={budget} selected={newExpenseBudget.value === budget}>{budget}</option>

View file

@ -1,8 +1,8 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js/auto';
import { formatNumber } from '/lib/utils/misc.ts'; import { formatNumber } from '/public/ts/utils/misc.ts';
import { Budget, SupportedCurrencySymbol } from '/lib/types.ts'; import { Budget, SupportedCurrencySymbol } from '/lib/types.ts';
interface ListBudgetsProps { interface ListBudgetsProps {

View file

@ -1,5 +1,6 @@
import { Expense, SupportedCurrencySymbol } from '/lib/types.ts'; import { Expense, SupportedCurrencySymbol } from '/lib/types.ts';
import { formatNumber } from '/lib/utils/misc.ts'; import { formatNumber } from '/public/ts/utils/misc.ts';
interface ListExpensesProps { interface ListExpensesProps {
expenses: Expense[]; expenses: Expense[];
currency: SupportedCurrencySymbol; currency: SupportedCurrencySymbol;
@ -47,14 +48,14 @@ export default function ListExpenses(
class='text-white border-t border-slate-700 hover:bg-slate-600 transition-colors even:bg-slate-700 odd:bg-slate-800 cursor-pointer flex md:table-row flex-row flex-wrap my-4 mx-4 md:my-0 md:mx-0 rounded md:rounded-none shadow-md md:shadow-none relative py-4 md:py-0 px-6 md:px-0' class='text-white border-t border-slate-700 hover:bg-slate-600 transition-colors even:bg-slate-700 odd:bg-slate-800 cursor-pointer flex md:table-row flex-row flex-wrap my-4 mx-4 md:my-0 md:mx-0 rounded md:rounded-none shadow-md md:shadow-none relative py-4 md:py-0 px-6 md:px-0'
onClick={() => onClickEditExpense(expense.id)} onClick={() => onClickEditExpense(expense.id)}
> >
<td class='md:px-6 md:py-3 flex-[50] mx-2 md:mx-0'>{expense.description}</td> <td class='md:px-6 md:py-3 flex-50 mx-2 md:mx-0'>{expense.description}</td>
<td class='md:px-6 md:py-3 flex-[20] mx-2 md:mx-0 text-gray-400 md:text-gray-300'> <td class='md:px-6 md:py-3 flex-20 mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
{expense.budget} {expense.budget}
</td> </td>
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 text-gray-400 md:text-gray-300'> <td class='md:px-6 md:py-3 flex-15 mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
{dateFormat.format(new Date(expense.date))} {dateFormat.format(new Date(expense.date))}
</td> </td>
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 font-bold md:font-semibold'> <td class='md:px-6 md:py-3 flex-15 mx-2 md:mx-0 font-bold md:font-semibold'>
{formatNumber(currency, expense.cost)} {formatNumber(currency, expense.cost)}
</td> </td>
</tr> </tr>

View file

@ -5,35 +5,32 @@ import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
import { import {
RequestBody as ImportRequestBody, RequestBody as ImportRequestBody,
ResponseBody as ImportResponseBody, ResponseBody as ImportResponseBody,
} from '/routes/api/expenses/import-expenses.tsx'; } from '/pages/api/expenses/import-expenses.ts';
import { import { ResponseBody as ExportResponseBody } from '/pages/api/expenses/export-expenses.ts';
RequestBody as ExportRequestBody,
ResponseBody as ExportResponseBody,
} from '/routes/api/expenses/export-expenses.tsx';
import { import {
RequestBody as AddExpenseRequestBody, RequestBody as AddExpenseRequestBody,
ResponseBody as AddExpenseResponseBody, ResponseBody as AddExpenseResponseBody,
} from '/routes/api/expenses/add-expense.tsx'; } from '/pages/api/expenses/add-expense.ts';
import { import {
RequestBody as AddBudgetRequestBody, RequestBody as AddBudgetRequestBody,
ResponseBody as AddBudgetResponseBody, ResponseBody as AddBudgetResponseBody,
} from '/routes/api/expenses/add-budget.tsx'; } from '/pages/api/expenses/add-budget.ts';
import { import {
RequestBody as UpdateExpenseRequestBody, RequestBody as UpdateExpenseRequestBody,
ResponseBody as UpdateExpenseResponseBody, ResponseBody as UpdateExpenseResponseBody,
} from '/routes/api/expenses/update-expense.tsx'; } from '/pages/api/expenses/update-expense.ts';
import { import {
RequestBody as UpdateBudgetRequestBody, RequestBody as UpdateBudgetRequestBody,
ResponseBody as UpdateBudgetResponseBody, ResponseBody as UpdateBudgetResponseBody,
} from '/routes/api/expenses/update-budget.tsx'; } from '/pages/api/expenses/update-budget.ts';
import { import {
RequestBody as DeleteExpenseRequestBody, RequestBody as DeleteExpenseRequestBody,
ResponseBody as DeleteExpenseResponseBody, ResponseBody as DeleteExpenseResponseBody,
} from '/routes/api/expenses/delete-expense.tsx'; } from '/pages/api/expenses/delete-expense.ts';
import { import {
RequestBody as DeleteBudgetRequestBody, RequestBody as DeleteBudgetRequestBody,
ResponseBody as DeleteBudgetResponseBody, ResponseBody as DeleteBudgetResponseBody,
} from '/routes/api/expenses/delete-budget.tsx'; } from '/pages/api/expenses/delete-budget.ts';
import ListBudgets from '/components/expenses/ListBudgets.tsx'; import ListBudgets from '/components/expenses/ListBudgets.tsx';
import ListExpenses from '/components/expenses/ListExpenses.tsx'; import ListExpenses from '/components/expenses/ListExpenses.tsx';
import ExpenseModal from './ExpenseModal.tsx'; import ExpenseModal from './ExpenseModal.tsx';
@ -167,10 +164,9 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`; const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`;
try { try {
const requestBody: ExportRequestBody = {};
const response = await fetch(`/api/expenses/export-expenses`, { const response = await fetch(`/api/expenses/export-expenses`, {
method: 'POST', method: 'POST',
body: JSON.stringify(requestBody), body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
@ -663,7 +659,9 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
placeholder='Filter expenses...' placeholder='Filter expenses...'
onInput={(event) => searchExpenses(event.currentTarget.value)} onInput={(event) => searchExpenses(event.currentTarget.value)}
/> />
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null} {isSearching.value
? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />
: null}
</section> </section>
</section> </section>
<section class='flex items-center justify-end w-full'> <section class='flex items-center justify-end w-full'>
@ -719,7 +717,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
onClick={() => toggleNewOptionsDropdown()} onClick={() => toggleNewOptionsDropdown()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new expense or budget' alt='Add new expense or budget'
class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`} class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -729,7 +727,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!areNewOptionsOption.value ? 'hidden' : '' !areNewOptionsOption.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -797,21 +795,21 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
{isSaving.value {isSaving.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</> </>
) )
: null} : null}
{isImporting.value {isImporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
</> </>
) )
: null} : null}
{isExporting.value {isExporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
</> </>
) )
: null} : null}

View file

@ -14,7 +14,7 @@ export default function CreateDirectoryModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>

View file

@ -16,7 +16,7 @@ export default function CreateShareModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>

View file

@ -1,7 +1,7 @@
import { join } from 'std/path/join.ts'; import { join } from '@std/path';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts'; import { humanFileSize, TRASH_PATH } from '/public/ts/utils/files.ts';
interface ListFilesProps { interface ListFilesProps {
directories: Directory[]; directories: Directory[];
@ -18,6 +18,7 @@ interface ListFilesProps {
onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>; onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
onClickCreateShare?: (filePath: string) => void; onClickCreateShare?: (filePath: string) => void;
onClickOpenManageShare?: (fileShareId: string) => void; onClickOpenManageShare?: (fileShareId: string) => void;
onClickDownloadDirectory?: (parentPath: string, name: string) => void;
isShowingNotes?: boolean; isShowingNotes?: boolean;
isShowingPhotos?: boolean; isShowingPhotos?: boolean;
fileShareId?: string; fileShareId?: string;
@ -39,6 +40,7 @@ export default function ListFiles(
onClickDeleteFile, onClickDeleteFile,
onClickCreateShare, onClickCreateShare,
onClickOpenManageShare, onClickOpenManageShare,
onClickDownloadDirectory,
isShowingNotes, isShowingNotes,
isShowingPhotos, isShowingPhotos,
fileShareId, fileShareId,
@ -141,7 +143,7 @@ export default function ListFiles(
class='flex items-center font-normal text-white' class='flex items-center font-normal text-white'
> >
<img <img
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`} src={`/public/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
class='white drop-shadow-md mr-2' class='white drop-shadow-md mr-2'
width={18} width={18}
height={18} height={18}
@ -156,7 +158,7 @@ export default function ListFiles(
</td> </td>
{isShowingNotes || isShowingPhotos ? null : ( {isShowingNotes || isShowingPhotos ? null : (
<td class='px-6 py-4 text-slate-200'> <td class='px-6 py-4 text-slate-200'>
- {humanFileSize(directory.size_in_bytes)}
</td> </td>
)} )}
{isShowingPhotos || fileShareId ? null : ( {isShowingPhotos || fileShareId ? null : (
@ -165,13 +167,29 @@ export default function ListFiles(
typeof onClickOpenMoveDirectory === 'undefined') typeof onClickOpenMoveDirectory === 'undefined')
? null ? null
: ( : (
<section class='flex items-center justify-end w-24'> <section class='flex items-center justify-end w-32'>
{typeof onClickDownloadDirectory === 'undefined' ? null : (
<span <span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)} onClick={() => onClickDownloadDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/rename.svg' src='/public/images/download.svg'
class='white drop-shadow-md'
width={18}
height={18}
alt='Download directory as zip'
title='Download directory as zip'
/>
</span>
)}
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() =>
onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
>
<img
src='/public/images/rename.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -181,11 +199,10 @@ export default function ListFiles(
</span> </span>
<span <span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
onClick={() => onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/move.svg' src='/public/images/move.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -199,7 +216,7 @@ export default function ListFiles(
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)} onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={20} width={20}
height={20} height={20}
@ -214,7 +231,7 @@ export default function ListFiles(
onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))} onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))}
> >
<img <img
src='/images/share.svg' src='/public/images/share.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -229,7 +246,7 @@ export default function ListFiles(
onClick={() => onClickOpenManageShare(directory.file_share_id!)} onClick={() => onClickOpenManageShare(directory.file_share_id!)}
> >
<img <img
src='/images/share.svg' src='/public/images/share.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -271,7 +288,7 @@ export default function ListFiles(
rel='noopener noreferrer' rel='noopener noreferrer'
> >
<img <img
src='/images/file.svg' src='/public/images/file.svg'
class='white drop-shadow-md mr-2' class='white drop-shadow-md mr-2'
width={18} width={18}
height={18} height={18}
@ -298,7 +315,7 @@ export default function ListFiles(
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)} onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
> >
<img <img
src='/images/rename.svg' src='/public/images/rename.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -313,7 +330,7 @@ export default function ListFiles(
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)} onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
> >
<img <img
src='/images/move.svg' src='/public/images/move.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -328,7 +345,7 @@ export default function ListFiles(
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)} onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={20} width={20}
height={20} height={20}
@ -343,7 +360,7 @@ export default function ListFiles(
onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))} onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))}
> >
<img <img
src='/images/share.svg' src='/public/images/share.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -358,7 +375,7 @@ export default function ListFiles(
onClick={() => onClickOpenManageShare(file.file_share_id!)} onClick={() => onClickOpenManageShare(file.file_share_id!)}
> >
<img <img
src='/images/share.svg' src='/public/images/share.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}

View file

@ -1,7 +1,7 @@
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts'; import { humanFileSize, TRASH_PATH } from '/public/ts/utils/files.ts';
interface ListFilesProps { interface ListPhotosProps {
directories: Directory[]; directories: Directory[];
files: DirectoryFile[]; files: DirectoryFile[];
onClickOpenRenameDirectory?: (parentPath: string, name: string) => void; onClickOpenRenameDirectory?: (parentPath: string, name: string) => void;
@ -13,7 +13,7 @@ interface ListFilesProps {
isShowingNotes?: boolean; isShowingNotes?: boolean;
} }
export default function ListFiles( export default function ListPhotos(
{ {
directories, directories,
files, files,
@ -24,7 +24,7 @@ export default function ListFiles(
onClickDeleteDirectory, onClickDeleteDirectory,
onClickDeleteFile, onClickDeleteFile,
isShowingNotes, isShowingNotes,
}: ListFilesProps, }: ListPhotosProps,
) { ) {
const dateFormatOptions: Intl.DateTimeFormatOptions = { const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',
@ -64,7 +64,7 @@ export default function ListFiles(
class='flex items-center font-normal text-white' class='flex items-center font-normal text-white'
> >
<img <img
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`} src={`/public/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
class='white drop-shadow-md mr-2' class='white drop-shadow-md mr-2'
width={18} width={18}
height={18} height={18}
@ -79,7 +79,7 @@ export default function ListFiles(
</td> </td>
{isShowingNotes ? null : ( {isShowingNotes ? null : (
<td class='px-6 py-4 text-slate-200'> <td class='px-6 py-4 text-slate-200'>
- {humanFileSize(directory.size_in_bytes)}
</td> </td>
)} )}
<td class='px-6 py-4'> <td class='px-6 py-4'>
@ -93,7 +93,7 @@ export default function ListFiles(
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)} onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/rename.svg' src='/public/images/rename.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -106,7 +106,7 @@ export default function ListFiles(
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)} onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/move.svg' src='/public/images/move.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -119,7 +119,7 @@ export default function ListFiles(
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)} onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={20} width={20}
height={20} height={20}
@ -145,7 +145,7 @@ export default function ListFiles(
rel='noopener noreferrer' rel='noopener noreferrer'
> >
<img <img
src='/images/file.svg' src='/public/images/file.svg'
class='white drop-shadow-md mr-2' class='white drop-shadow-md mr-2'
width={18} width={18}
height={18} height={18}
@ -171,7 +171,7 @@ export default function ListFiles(
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)} onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
> >
<img <img
src='/images/rename.svg' src='/public/images/rename.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -186,7 +186,7 @@ export default function ListFiles(
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)} onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
> >
<img <img
src='/images/move.svg' src='/public/images/move.svg'
class='white drop-shadow-md' class='white drop-shadow-md'
width={18} width={18}
height={18} height={18}
@ -200,7 +200,7 @@ export default function ListFiles(
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)} onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={20} width={20}
height={20} height={20}

View file

@ -1,38 +1,38 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx'; import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx'; import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/pages/api/files/rename.ts';
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx'; import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/pages/api/files/move.ts';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/files/delete.ts';
import { import {
RequestBody as CreateDirectoryRequestBody, RequestBody as CreateDirectoryRequestBody,
ResponseBody as CreateDirectoryResponseBody, ResponseBody as CreateDirectoryResponseBody,
} from '/routes/api/files/create-directory.tsx'; } from '/pages/api/files/create-directory.ts';
import { import {
RequestBody as RenameDirectoryRequestBody, RequestBody as RenameDirectoryRequestBody,
ResponseBody as RenameDirectoryResponseBody, ResponseBody as RenameDirectoryResponseBody,
} from '/routes/api/files/rename-directory.tsx'; } from '/pages/api/files/rename-directory.ts';
import { import {
RequestBody as MoveDirectoryRequestBody, RequestBody as MoveDirectoryRequestBody,
ResponseBody as MoveDirectoryResponseBody, ResponseBody as MoveDirectoryResponseBody,
} from '/routes/api/files/move-directory.tsx'; } from '/pages/api/files/move-directory.ts';
import { import {
RequestBody as DeleteDirectoryRequestBody, RequestBody as DeleteDirectoryRequestBody,
ResponseBody as DeleteDirectoryResponseBody, ResponseBody as DeleteDirectoryResponseBody,
} from '/routes/api/files/delete-directory.tsx'; } from '/pages/api/files/delete-directory.ts';
import { import {
RequestBody as CreateShareRequestBody, RequestBody as CreateShareRequestBody,
ResponseBody as CreateShareResponseBody, ResponseBody as CreateShareResponseBody,
} from '/routes/api/files/create-share.tsx'; } from '/pages/api/files/create-share.ts';
import { import {
RequestBody as UpdateShareRequestBody, RequestBody as UpdateShareRequestBody,
ResponseBody as UpdateShareResponseBody, ResponseBody as UpdateShareResponseBody,
} from '/routes/api/files/update-share.tsx'; } from '/pages/api/files/update-share.ts';
import { import {
RequestBody as DeleteShareRequestBody, RequestBody as DeleteShareRequestBody,
ResponseBody as DeleteShareResponseBody, ResponseBody as DeleteShareResponseBody,
} from '/routes/api/files/delete-share.tsx'; } from '/pages/api/files/delete-share.ts';
import SearchFiles from './SearchFiles.tsx'; import SearchFiles from './SearchFiles.tsx';
import ListFiles from './ListFiles.tsx'; import ListFiles from './ListFiles.tsx';
import FilesBreadcrumb from './FilesBreadcrumb.tsx'; import FilesBreadcrumb from './FilesBreadcrumb.tsx';
@ -48,6 +48,7 @@ interface MainFilesProps {
initialPath: string; initialPath: string;
baseUrl: string; baseUrl: string;
isFileSharingAllowed: boolean; isFileSharingAllowed: boolean;
areDirectoryDownloadsAllowed: boolean;
fileShareId?: string; fileShareId?: string;
} }
@ -58,6 +59,7 @@ export default function MainFiles(
initialPath, initialPath,
baseUrl, baseUrl,
isFileSharingAllowed, isFileSharingAllowed,
areDirectoryDownloadsAllowed,
fileShareId, fileShareId,
}: MainFilesProps, }: MainFilesProps,
) { ) {
@ -411,6 +413,21 @@ export default function MainFiles(
moveDirectoryOrFileModal.value = null; moveDirectoryOrFileModal.value = null;
} }
function onClickDownloadDirectory(parentPath: string, name: string) {
// Create download URL with proper path encoding
const downloadUrl = `/api/files/download-directory?parentPath=${encodeURIComponent(parentPath)}&name=${
encodeURIComponent(name)
}`;
// Create a temporary anchor element to trigger download
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${name}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async function onClickDeleteDirectory(parentPath: string, name: string, isBulkDeleting = false) { async function onClickDeleteDirectory(parentPath: string, name: string, isBulkDeleting = false) {
if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) { if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) {
if (!isBulkDeleting && isDeleting.value) { if (!isBulkDeleting && isDeleting.value) {
@ -724,7 +741,7 @@ export default function MainFiles(
onClick={() => toggleBulkOptionsDropdown()} onClick={() => toggleBulkOptionsDropdown()}
> >
<img <img
src={`/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`} src={`/public/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`}
alt='Bulk actions' alt='Bulk actions'
class={`white w-5 max-w-5`} class={`white w-5 max-w-5`}
width={20} width={20}
@ -734,7 +751,7 @@ export default function MainFiles(
</div> </div>
<div <div
class={`absolute left-0 z-10 mt-2 w-44 origin-top-left rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute left-0 z-10 mt-2 w-44 origin-top-left rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!areBulkOptionsOpen.value ? 'hidden' : '' !areBulkOptionsOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -775,7 +792,7 @@ export default function MainFiles(
onClick={() => toggleNewOptionsDropdown()} onClick={() => toggleNewOptionsDropdown()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new file or directory' alt='Add new file or directory'
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -785,7 +802,7 @@ export default function MainFiles(
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!areNewOptionsOpen.value ? 'hidden' : '' !areNewOptionsOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -839,6 +856,7 @@ export default function MainFiles(
onClickDeleteFile={onClickDeleteFile} onClickDeleteFile={onClickDeleteFile}
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined} onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined} onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
onClickDownloadDirectory={areDirectoryDownloadsAllowed ? onClickDownloadDirectory : undefined}
fileShareId={fileShareId} fileShareId={fileShareId}
/> />
@ -848,28 +866,28 @@ export default function MainFiles(
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}
{isAdding.value {isAdding.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
</> </>
) )
: null} : null}
{isUploading.value {isUploading.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
</> </>
) )
: null} : null}
{isUpdating.value {isUpdating.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Updating... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
</> </>
) )
: null} : null}

View file

@ -1,7 +1,7 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { RequestBody, ResponseBody } from '/routes/api/files/get-share.tsx'; import { RequestBody, ResponseBody } from '/pages/api/files/get-share.ts';
import { FileShare } from '/lib/types.ts'; import { FileShare } from '/lib/types.ts';
interface ManageShareModalProps { interface ManageShareModalProps {
@ -62,7 +62,7 @@ export default function ManageShareModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>

View file

@ -1,7 +1,7 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { RequestBody, ResponseBody } from '/routes/api/files/get-directories.tsx'; import { RequestBody, ResponseBody } from '/pages/api/files/get-directories.ts';
import { Directory } from '/lib/types.ts'; import { Directory } from '/lib/types.ts';
interface MoveDirectoryOrFileModalProps { interface MoveDirectoryOrFileModalProps {
@ -78,7 +78,7 @@ export default function MoveDirectoryOrFileModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>
@ -123,7 +123,7 @@ export default function MoveDirectoryOrFileModal(
{isLoading.value {isLoading.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Loading... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
</> </>
) )
: null} : null}

View file

@ -21,7 +21,7 @@ export default function RenameDirectoryOrFileModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>

View file

@ -2,10 +2,9 @@ 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 '/pages/api/files/search.ts';
interface SearchFilesProps {}
export default function SearchFiles({}: SearchFilesProps) { export default function SearchFiles() {
const isSearching = useSignal<boolean>(false); const isSearching = useSignal<boolean>(false);
const areResultsVisible = useSignal<boolean>(false); const areResultsVisible = useSignal<boolean>(false);
const matchingDirectories = useSignal<Directory[]>([]); const matchingDirectories = useSignal<Directory[]>([]);
@ -107,12 +106,12 @@ export default function SearchFiles({}: SearchFilesProps) {
onFocus={() => onFocus()} onFocus={() => onFocus()}
onBlur={() => onBlur()} onBlur={() => onBlur()}
/> />
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null} {isSearching.value ? <img src='/public/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-sm'> <section class='relative inline-block text-left ml-2 text-sm'>
<section <section
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`} class={`absolute right-0 z-10 mt-2 w-80 origin-top-right rounded-md bg-slate-600 shadow-lg ring-1 ring-black/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'

View file

@ -6,7 +6,7 @@ export default function ShareVerifyForm(
{ error }: ShareVerifyFormProps, { error }: ShareVerifyFormProps,
) { ) {
return ( return (
<section class='max-w-md w-full mb-12'> <section class='max-w-md w-full mb-12 mx-auto'>
<section class='mb-6'> <section class='mb-6'>
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'> <h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
File Share Authentication File Share Authentication

View file

@ -1,11 +1,8 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { NewsFeedArticle } from '/lib/types.ts'; import { NewsFeedArticle } from '/lib/types.ts';
import { import { ResponseBody as RefreshResponseBody } from '/pages/api/news/refresh-articles.ts';
RequestBody as RefreshRequestBody, import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/pages/api/news/mark-read.ts';
ResponseBody as RefreshResponseBody,
} from '/routes/api/news/refresh-articles.tsx';
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/routes/api/news/mark-read.tsx';
interface ArticlesProps { interface ArticlesProps {
initialArticles: NewsFeedArticle[]; initialArticles: NewsFeedArticle[];
@ -15,6 +12,8 @@ interface Filter {
status: 'all' | 'unread'; status: 'all' | 'unread';
} }
let hasFetchedAllArticlesOnce = false;
export default function Articles({ initialArticles }: ArticlesProps) { export default function Articles({ initialArticles }: ArticlesProps) {
const isRefreshing = useSignal<boolean>(false); const isRefreshing = useSignal<boolean>(false);
const articles = useSignal<NewsFeedArticle[]>(initialArticles); const articles = useSignal<NewsFeedArticle[]>(initialArticles);
@ -34,10 +33,9 @@ export default function Articles({ initialArticles }: ArticlesProps) {
isRefreshing.value = true; isRefreshing.value = true;
try { try {
const requestBody: RefreshRequestBody = {};
const response = await fetch(`/api/news/refresh-articles`, { const response = await fetch(`/api/news/refresh-articles`, {
method: 'POST', method: 'POST',
body: JSON.stringify(requestBody), body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
@ -148,6 +146,11 @@ export default function Articles({ initialArticles }: ArticlesProps) {
function setNewFilter(newFilter: Partial<Filter>) { function setNewFilter(newFilter: Partial<Filter>) {
filter.value = { ...filter.value, ...newFilter }; filter.value = { ...filter.value, ...newFilter };
if (newFilter.status === 'all' && !hasFetchedAllArticlesOnce) {
refreshArticles();
hasFetchedAllArticlesOnce = true;
}
isFilterDropdownOpen.value = false; isFilterDropdownOpen.value = false;
} }
@ -178,7 +181,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!isFilterDropdownOpen.value ? 'hidden' : '' !isFilterDropdownOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -216,7 +219,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
onClick={() => onClickMarkAllRead()} onClick={() => onClickMarkAllRead()}
> >
<img <img
src='/images/check-all.svg' src='/public/images/check-all.svg'
alt='Mark all read' alt='Mark all read'
class={`white`} class={`white`}
width={20} width={20}
@ -231,7 +234,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
onClick={() => refreshArticles()} onClick={() => refreshArticles()}
> >
<img <img
src='/images/refresh.svg' src='/public/images/refresh.svg'
alt='Fetch new articles' alt='Fetch new articles'
class={`white ${isRefreshing.value ? 'animate-spin' : ''}`} class={`white ${isRefreshing.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -251,7 +254,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`} class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`}
> >
<summary <summary
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-[:first-child]:rounded-tl-md group-[:first-child]:rounded-tr-md ${ class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-first:rounded-tl-md group-first:rounded-tr-md ${
article.is_read ? 'opacity-50' : 'font-semibold' article.is_read ? 'opacity-50' : 'font-semibold'
}`} }`}
onClick={() => onClickView(article.id)} onClick={() => onClickView(article.id)}
@ -266,7 +269,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
</article> </article>
<a <a
href={article.article_url} href={article.article_url}
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-[:last-child]:rounded-bl-md group-[:last-child]:rounded-br-md' class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-last:rounded-bl-md group-last:rounded-br-md'
target='_blank' target='_blank'
rel='noreferrer noopener' rel='noreferrer noopener'
onClick={() => onClickView(article.id)} onClick={() => onClickView(article.id)}

View file

@ -1,13 +1,10 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { NewsFeed } from '/lib/types.ts'; import { NewsFeed } from '/lib/types.ts';
import { escapeHtml, validateUrl } from '/lib/utils/misc.ts'; import { escapeHtml, validateUrl } from '/public/ts/utils/misc.ts';
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx'; import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/news/add-feed.ts';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/news/delete-feed.ts';
import { import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/news/import-feeds.ts';
RequestBody as ImportRequestBody,
ResponseBody as ImportResponseBody,
} from '/routes/api/news/import-feeds.tsx';
interface FeedsProps { interface FeedsProps {
initialFeeds: NewsFeed[]; initialFeeds: NewsFeed[];
@ -257,7 +254,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!isOptionsDropdownOpen.value ? 'hidden' : '' !isOptionsDropdownOpen.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -290,7 +287,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
onClick={() => onClickAddFeed()} onClick={() => onClickAddFeed()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new feed' alt='Add new feed'
class={`white ${isAdding.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -333,7 +330,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
onClick={() => onClickDeleteFeed(newsFeed.id)} onClick={() => onClickDeleteFeed(newsFeed.id)}
> >
<img <img
src='/images/delete.svg' src='/public/images/delete.svg'
class='red drop-shadow-md' class='red drop-shadow-md'
width={24} width={24}
height={24} height={24}
@ -364,21 +361,21 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}
{isExporting.value {isExporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
</> </>
) )
: null} : null}
{isImporting.value {isImporting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
</> </>
) )
: null} : null}

View file

@ -14,7 +14,7 @@ export default function CreateNoteModal(
return ( return (
<> <>
<section <section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`} class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
> >
</section> </section>

View file

@ -1,16 +1,16 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx'; import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx'; import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/files/delete.ts';
import { import {
RequestBody as CreateDirectoryRequestBody, RequestBody as CreateDirectoryRequestBody,
ResponseBody as CreateDirectoryResponseBody, ResponseBody as CreateDirectoryResponseBody,
} from '/routes/api/files/create-directory.tsx'; } from '/pages/api/files/create-directory.ts';
import { import {
RequestBody as DeleteDirectoryRequestBody, RequestBody as DeleteDirectoryRequestBody,
ResponseBody as DeleteDirectoryResponseBody, ResponseBody as DeleteDirectoryResponseBody,
} from '/routes/api/files/delete-directory.tsx'; } from '/pages/api/files/delete-directory.ts';
import ListFiles from '/components/files/ListFiles.tsx'; import ListFiles from '/components/files/ListFiles.tsx';
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx'; import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx'; import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
@ -240,7 +240,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
onClick={() => toggleNewOptionsDropdown()} onClick={() => toggleNewOptionsDropdown()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new note or directory' alt='Add new note or directory'
class={`white ${isAdding.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -250,7 +250,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!areNewOptionsOption.value ? 'hidden' : '' !areNewOptionsOption.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -294,14 +294,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
{isDeleting.value {isDeleting.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</> </>
) )
: null} : null}
{isAdding.value {isAdding.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
</> </>
) )
: null} : null}

View file

@ -1,7 +1,7 @@
import { useSignal, useSignalEffect } from '@preact/signals'; import { useSignal, useSignalEffect } from '@preact/signals';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { RequestBody, ResponseBody } from '/routes/api/notes/save.tsx'; import { RequestBody, ResponseBody } from '/pages/api/notes/save.ts';
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx'; import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
interface NoteProps { interface NoteProps {
@ -96,14 +96,14 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
{isSaving.value {isSaving.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</> </>
) )
: null} : null}
{hasSaved.value {hasSaved.value
? ( ? (
<> <>
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved! <img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
</> </>
) )
: null} : null}

View file

@ -1,5 +1,5 @@
import { DirectoryFile } from '/lib/types.ts'; import { DirectoryFile } from '/lib/types.ts';
import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/lib/utils/photos.ts'; import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/public/ts/utils/photos.ts';
interface ListPhotosProps { interface ListPhotosProps {
files: DirectoryFile[]; files: DirectoryFile[];

View file

@ -1,11 +1,11 @@
import { useSignal } from '@preact/signals'; import { useSignal } from '@preact/signals';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx'; import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
import { import {
RequestBody as CreateDirectoryRequestBody, RequestBody as CreateDirectoryRequestBody,
ResponseBody as CreateDirectoryResponseBody, ResponseBody as CreateDirectoryResponseBody,
} from '/routes/api/files/create-directory.tsx'; } from '/pages/api/files/create-directory.ts';
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx'; import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
import ListFiles from '/components/files/ListFiles.tsx'; import ListFiles from '/components/files/ListFiles.tsx';
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx'; import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
@ -156,7 +156,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
onClick={() => toggleNewOptionsDropdown()} onClick={() => toggleNewOptionsDropdown()}
> >
<img <img
src='/images/add.svg' src='/public/images/add.svg'
alt='Add new file or directory' alt='Add new file or directory'
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`} class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
width={20} width={20}
@ -166,7 +166,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
</div> </div>
<div <div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${ class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
!areNewOptionsOption.value ? 'hidden' : '' !areNewOptionsOption.value ? 'hidden' : ''
}`} }`}
role='menu' role='menu'
@ -212,14 +212,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
{isAdding.value {isAdding.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
</> </>
) )
: null} : null}
{isUploading.value {isUploading.value
? ( ? (
<> <>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading... <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
</> </>
) )
: null} : null}

View file

@ -1,4 +1,4 @@
import { Cron } from 'https://deno.land/x/croner@8.1.2/dist/croner.js'; import { Cron } from '@hexagon/croner';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { cleanupSessions } from './sessions.ts'; import { cleanupSessions } from './sessions.ts';

View file

@ -1,6 +1,6 @@
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';
import { NewsFeed } from '/lib/types.ts'; import { NewsFeed } from '/lib/types.ts';
import { concurrentPromises } from '/lib/utils/misc.ts'; import { concurrentPromises } from '/public/ts/utils/misc.ts';
import { FeedModel } from '/lib/models/news.ts'; import { FeedModel } from '/lib/models/news.ts';
const db = new Database(); const db = new Database();

119
deno.json
View file

@ -1,14 +1,19 @@
{ {
"lock": false, "lock": true,
"tasks": { "tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", "execute-with-permissions": "deno run --allow-env --allow-net --allow-sys=networkInterfaces,hostname,cpus,homedir --allow-read=.,/ --allow-write=data-files,/ --allow-run",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", "check": "deno fmt --check && deno lint && deno check .",
"manifest": "deno task cli manifest $(pwd)", "build": "make build",
"start": "deno run -A --watch=static/,routes/,lib/,components/,islands/ dev.ts", "preview": "deno task execute-with-permissions ./main.ts",
"build": "deno run -A dev.ts build", "test": "IS_TESTING=true deno test --allow-all --check",
"preview": "deno run -A main.ts", "migrate-db": "deno task execute-with-permissions ./migrate-db.ts",
"update": "deno run -A -r https://fresh.deno.dev/update .", "watch-app": "deno task execute-with-permissions --watch --watch-exclude=./public/css/tailwind.css,./public/components ./main.ts",
"test": "deno test -A --check" "watch-babel": "make watch-babel",
"watch-tailwind": "make watch-tailwind",
"download-frontend-imports": "deno run --allow-read=./deno.json,./public/js --allow-write=./public/js --allow-net=esm.sh ./download-frontend-imports.ts",
"start": {
"dependencies": ["watch-tailwind", "watch-babel", "watch-app"]
}
}, },
"fmt": { "fmt": {
"useTabs": false, "useTabs": false,
@ -16,42 +21,78 @@
"indentWidth": 2, "indentWidth": 2,
"singleQuote": true, "singleQuote": true,
"proseWrap": "preserve", "proseWrap": "preserve",
"exclude": ["README.md"] "exclude": ["README.md", "lib/models/dav.js", "public/css/tailwind.css"]
}, },
"lint": { "lint": {
"rules": { "rules": {
"tags": ["fresh", "recommended"], "tags": ["recommended"],
"exclude": ["no-explicit-any", "no-empty-interface", "no-window", "no-unused-vars"] "exclude": ["no-explicit-any", "no-window"]
} },
"exclude": ["lib/models/dav.js"]
},
"exclude": ["./node_modules/*", "./public/js/*", "./public/components/*"],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": [
"dom",
"dom.iterable",
"dom.asynciterable",
"deno.ns"
]
}, },
"exclude": ["./_fresh/*", "./node_modules/*", "**/_fresh/*"],
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"imports": { "imports": {
"/": "./", "/": "./",
"./": "./", "./": "./",
"xml": "https://deno.land/x/xml@2.1.3/mod.ts",
"mrmime": "https://deno.land/x/mrmime@v2.0.0/mod.ts", "sass": "https://deno.land/x/denosass@1.0.6/mod.ts",
"fresh/": "https://deno.land/x/fresh@1.7.3/", "@libs/xml": "https://deno.land/x/xml@2.1.3/mod.ts",
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"std/": "https://deno.land/std@0.224.0/", "chart.js": "https://esm.sh/chart.js@4.5.1",
"$std/": "https://deno.land/std@0.224.0/", "chart.js/auto": "https://esm.sh/chart.js@4.5.1/auto",
"postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts", "preact": "https://esm.sh/preact@10.28.4",
"preact": "https://esm.sh/preact@10.23.2", "preact/jsx-runtime": "https://esm.sh/preact@10.28.4/jsx-runtime",
"preact/": "https://esm.sh/preact@10.23.2/", "preact/hooks": "https://esm.sh/preact@10.28.4/hooks",
"@preact/signals": "https://esm.sh/*@preact/signals@1.3.0", "preact-render-to-string": "https://esm.sh/preact-render-to-string@6.6.5?deps=preact@10.28.4",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.8.0", "@preact/signals": "https://esm.sh/*@preact/signals@2.8.1?deps=preact@10.28.4",
"chart.js": "https://esm.sh/chart.js@4.4.9/auto", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.13.0?deps=preact@10.28.4",
"otpauth": "https://esm.sh/otpauth@9.4.0", "@simplewebauthn/browser": "https://esm.sh/@simplewebauthn/browser@13.2.2",
"qrcode": "https://esm.sh/qrcode@1.5.4", "@std/path": "https://esm.sh/jsr/@std/path@1.1.4",
"openid-client": "https://esm.sh/openid-client@6.6.3",
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.2", "deno/emit": "jsr:@deno/emit@0.46.0",
"@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.2/helpers", "postgres": "jsr:@db/postgres@0.19.5",
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.2", "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@0.1.56",
"tailwindcss": "npm:tailwindcss@3.4.17", "@hexagon/croner": "jsr:@hexagon/croner@10.0.1",
"tailwindcss/": "npm:/tailwindcss@3.4.17/", "@mikaelporttila/rss": "jsr:@mikaelporttila/rss@1.1.3",
"tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js", "@libs/qrcode": "jsr:@libs/qrcode@3.0.1",
"nodemailer": "npm:nodemailer@7.0.5", "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.2.2",
"tsdav": "https://raw.githubusercontent.com/sunsama/tsdav/cc1c5a09b64c87bbee7e5f171cfcb6748e99469e/dist/tsdav.js" "@std/assert": "jsr:@std/assert@1.0.18",
} "@std/dotenv": "jsr:@std/dotenv@0.225.6",
"@std/encoding": "jsr:@std/encoding@1.0.10",
"@std/http": "jsr:@std/http@1.0.24",
"jimp": "npm:jimp@1.6.0",
"mrmime": "npm:mrmime@2.0.1",
"nodemailer": "npm:nodemailer@8.0.1",
"openid-client": "npm:openid-client@6.8.2",
"otpauth": "npm:otpauth@9.5.0",
"tailwindcss": "npm:tailwindcss@4.2.0",
"@tailwindcss/cli": "npm:@tailwindcss/cli@4.2.0",
"@babel/cli": "npm:@babel/cli@7.28.6",
"@babel/core": "npm:@babel/core@7.29.0",
"@babel/plugin-transform-react-jsx": "npm:@babel/plugin-transform-react-jsx@7.28.6",
"@babel/preset-react": "npm:@babel/preset-react@7.28.5",
"@babel/preset-typescript": "npm:@babel/preset-typescript@7.28.5"
},
"frontendImports": [
"chart.js",
"chart.js/auto",
"preact",
"preact/hooks",
"@preact/signals",
"@preact/signals-core",
"@simplewebauthn/browser",
"@std/path"
]
} }

2175
deno.lock Normal file

File diff suppressed because it is too large Load diff

8
dev.ts
View file

@ -1,8 +0,0 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/,lib/,components/,islands/
import dev from 'fresh/dev.ts';
import config from './fresh.config.ts';
import 'std/dotenv/load.ts';
await dev(import.meta.url, './main.ts', config);

View file

@ -1,13 +1,13 @@
services: services:
postgresql: postgresql:
image: postgres:17 image: postgres:18.1
environment: environment:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=fake - POSTGRES_PASSWORD=fake
- POSTGRES_DB=bewcloud - POSTGRES_DB=bewcloud
restart: on-failure restart: on-failure
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/18/docker
ports: ports:
- 5432:5432 - 5432:5432
ulimits: ulimits:
@ -18,7 +18,7 @@ services:
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service. # NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
radicale: radicale:
image: tomsquest/docker-radicale:3.5.4.0 image: tomsquest/docker-radicale:3.6.0.0
ports: ports:
- 5232:5232 - 5232:5232
init: true init: true

View file

@ -1,6 +1,10 @@
services: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v2.5.3 image: ghcr.io/bewcloud/bewcloud:v4.1.2
# NOTE: uncomment below (and comment above) only if you pulled the repo and want to build the image locally
# build:
# context: .
# dockerfile: Dockerfile
restart: always restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000
@ -13,14 +17,14 @@ services:
- ./bewcloud.config.ts:/app/bewcloud.config.ts - ./bewcloud.config.ts:/app/bewcloud.config.ts
postgresql: postgresql:
image: postgres:17 image: postgres:18.1
environment: environment:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=fake - POSTGRES_PASSWORD=fake
- POSTGRES_DB=bewcloud - POSTGRES_DB=bewcloud
restart: always restart: always
volumes: volumes:
- bewcloud-db:/var/lib/postgresql/data - bewcloud-db:/var/lib/postgresql/18/docker
# NOTE: uncomment below only if you need to connect to the database from outside the container # NOTE: uncomment below only if you need to connect to the database from outside the container
# ports: # ports:
# - 127.0.0.1:5432:5432 # - 127.0.0.1:5432:5432
@ -32,7 +36,7 @@ services:
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service. # NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
radicale: radicale:
image: tomsquest/docker-radicale:3.5.4.0 image: tomsquest/docker-radicale:3.6.0.0
# NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container # NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container
# ports: # ports:
# - 127.0.0.1:5232:5232 # - 127.0.0.1:5232:5232

View file

@ -0,0 +1,183 @@
import denoConfig from '/deno.json' with { type: 'json' };
import { dirname } from '@std/path';
const downloadDirectory = `${Deno.cwd()}/public/js`;
function extractFileNameFromUrl(url: string) {
let fileName = url.replace('https://esm.sh/', '').split('?')[0].trim();
if (!fileName.endsWith('.mjs') && !fileName.includes('color@^0.3.0')) {
fileName = `${fileName}.mjs`;
}
return fileName;
}
async function ensureDirectoryExistsForFileName(fileName: string) {
const directory = dirname(fileName);
try {
const stat = await Deno.stat(directory);
if (!stat.isDirectory) {
throw new Error(`Directory ${directory} is not a directory`);
}
} catch (error) {
if ((error as Error).toString().includes('NotFound')) {
await Deno.mkdir(directory, { recursive: true });
}
}
}
function getUrlsToDownload(): Map<string, { url: string; fileName: string }> {
const urlMap = new Map<string, { url: string; fileName: string }>();
const { imports, frontendImports } = denoConfig;
for (const importName of frontendImports) {
const url = new URL(imports[importName as keyof typeof imports]);
// url.searchParams.append('standalone', ''); // This should be easier, but it results in broken bundles (probably because of the missing peerDependencies)
const finalUrl = url.toString();
urlMap.set(importName, { url: finalUrl, fileName: extractFileNameFromUrl(finalUrl) });
}
return urlMap;
}
async function downloadUrls(urlMap: Map<string, { url: string; fileName: string }>) {
for (const { url, fileName } of urlMap.values()) {
console.log(`Fetching source file ${fileName} (${url})...`);
const response = await fetch(url);
const sourceContent = await response.text();
const sourceLines = sourceContent.split('\n');
for (const sourceLine of sourceLines) {
const dependencyUrlsMap = new Map<string, { url: string; fileName: string }>();
const importUrlInSourceContent = sourceLine.split('import "')[1]?.split('"')[0];
const exportUrlInSourceContent = sourceLine.split('export * from "')[1]?.split('"')[0];
const defaultExportUrlInSourceContent = sourceLine.split('export { default } from "')[1]?.split('"')[0];
if (importUrlInSourceContent) {
const importUrl = new URL(`https://esm.sh${importUrlInSourceContent}`).toString();
dependencyUrlsMap.set(importUrlInSourceContent, {
url: importUrl,
fileName: extractFileNameFromUrl(importUrl),
});
}
if (exportUrlInSourceContent) {
const exportUrl = new URL(`https://esm.sh${exportUrlInSourceContent}`).toString();
dependencyUrlsMap.set(exportUrlInSourceContent, {
url: exportUrl,
fileName: extractFileNameFromUrl(exportUrl),
});
}
if (defaultExportUrlInSourceContent) {
const defaultExportUrl = new URL(`https://esm.sh${defaultExportUrlInSourceContent}`).toString();
dependencyUrlsMap.set(defaultExportUrlInSourceContent, {
url: defaultExportUrl,
fileName: extractFileNameFromUrl(defaultExportUrl),
});
}
await downloadUrls(dependencyUrlsMap);
continue;
}
console.log(`Downloading bundle file ${fileName} (${url})...`);
const bundleResponse = await fetch(url);
let bundleFileContent = await bundleResponse.text();
// Update absolute import paths
bundleFileContent = bundleFileContent.replaceAll('import "/', 'import "/public/js/');
bundleFileContent = bundleFileContent.replaceAll('import"/', 'import"/public/js/');
// Update absolute export paths
bundleFileContent = bundleFileContent.replaceAll('export * from "/', 'export * from "/public/js/');
bundleFileContent = bundleFileContent.replaceAll('from"/', 'from"/public/js/'); // minified files
// Update absolute default export paths
bundleFileContent = bundleFileContent.replaceAll(
'export { default } from "/',
'export { default } from "/public/js/',
);
// Remove sourcemap URLs (they're not downloaded)
bundleFileContent = bundleFileContent.replaceAll('//# sourceMappingURL=', '//');
const fullFilePath = `${downloadDirectory}/${fileName}`;
console.log(`Writing bundle file ${fileName} (${fullFilePath})...`);
await ensureDirectoryExistsForFileName(fullFilePath);
await Deno.writeTextFile(fullFilePath, bundleFileContent);
}
}
async function main() {
const urlMap = getUrlsToDownload();
// These are some extra imports that are harder to match from analyzing the imported files (just related to @std/path)
urlMap.set('@std/path/internals', {
url: 'https://esm.sh/@jsr/std__internal@^1.0.12/os',
fileName: '@jsr/std__internal@^1.0.12/os',
});
const osPaths = [
'basename',
'constants',
'dirname',
'extname',
'format',
'from-file-url',
'glob-to-regexp',
'is-absolute',
'join-globs',
'join',
'normalize-glob',
'normalize',
'parse',
'relative',
'resolve',
'to-file-url',
'to-namespaced-path',
'_util',
];
for (const path of osPaths) {
urlMap.set(`@std/path/posix/${path}`, {
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/posix/${path}.mjs`,
fileName: `@jsr/std__path@1.1.4/denonext/posix/${path}.mjs`,
});
urlMap.set(`@std/path/windows/${path}`, {
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/windows/${path}.mjs`,
fileName: `@jsr/std__path@1.1.4/denonext/windows/${path}.mjs`,
});
}
const commonPaths = [
'common',
'basename',
'assert_path',
'strip_trailing_separators',
'dirname',
'constants',
'format',
'from_file_url',
'glob_to_reg_exp',
'normalize',
'normalize_string',
'relative',
'to_file_url',
];
for (const path of commonPaths) {
urlMap.set(`@std/path/common/${path}`, {
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/_common/${path}.mjs`,
fileName: `@jsr/std__path@1.1.4/denonext/_common/${path}.mjs`,
});
}
await downloadUrls(urlMap);
console.log('Done');
Deno.exit(0);
}
main();

View file

@ -1,14 +0,0 @@
import { defineConfig } from 'fresh/server.ts';
import tailwind from 'fresh/plugins/tailwind.ts';
import { startCrons } from '/crons/index.ts';
const isBuildMode = Deno.args.includes('build');
if (!isBuildMode) {
await startCrons();
}
export default defineConfig({
plugins: [tailwind()],
});

View file

@ -1,225 +0,0 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $_well_known_caldav from './routes/.well-known/caldav.tsx';
import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
import * as $_404 from './routes/_404.tsx';
import * as $_app from './routes/_app.tsx';
import * as $_middleware from './routes/_middleware.tsx';
import * as $api_auth_multi_factor_disable from './routes/api/auth/multi-factor/disable.ts';
import * as $api_auth_multi_factor_email_setup from './routes/api/auth/multi-factor/email/setup.ts';
import * as $api_auth_multi_factor_enable from './routes/api/auth/multi-factor/enable.ts';
import * as $api_auth_multi_factor_passkey_begin from './routes/api/auth/multi-factor/passkey/begin.ts';
import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/multi-factor/passkey/setup-begin.ts';
import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts';
import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts';
import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts';
import * as $api_calendar_add_event from './routes/api/calendar/add-event.tsx';
import * as $api_calendar_add from './routes/api/calendar/add.tsx';
import * as $api_calendar_delete_event from './routes/api/calendar/delete-event.tsx';
import * as $api_calendar_delete from './routes/api/calendar/delete.tsx';
import * as $api_calendar_export_events from './routes/api/calendar/export-events.tsx';
import * as $api_calendar_import from './routes/api/calendar/import.tsx';
import * as $api_calendar_search_events from './routes/api/calendar/search-events.tsx';
import * as $api_calendar_update from './routes/api/calendar/update.tsx';
import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx';
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx';
import * as $api_contacts_delete from './routes/api/contacts/delete.tsx';
import * as $api_contacts_get_addressbooks from './routes/api/contacts/get-addressbooks.tsx';
import * as $api_contacts_get from './routes/api/contacts/get.tsx';
import * as $api_contacts_import from './routes/api/contacts/import.tsx';
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx';
import * as $api_expenses_add_expense from './routes/api/expenses/add-expense.tsx';
import * as $api_expenses_auto_complete from './routes/api/expenses/auto-complete.tsx';
import * as $api_expenses_delete_budget from './routes/api/expenses/delete-budget.tsx';
import * as $api_expenses_delete_expense from './routes/api/expenses/delete-expense.tsx';
import * as $api_expenses_export_expenses from './routes/api/expenses/export-expenses.tsx';
import * as $api_expenses_import_expenses from './routes/api/expenses/import-expenses.tsx';
import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx';
import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx';
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
import * as $api_files_create_share from './routes/api/files/create-share.tsx';
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
import * as $api_files_delete_share from './routes/api/files/delete-share.tsx';
import * as $api_files_delete from './routes/api/files/delete.tsx';
import * as $api_files_get_directories from './routes/api/files/get-directories.tsx';
import * as $api_files_get_share from './routes/api/files/get-share.tsx';
import * as $api_files_get from './routes/api/files/get.tsx';
import * as $api_files_move_directory from './routes/api/files/move-directory.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 from './routes/api/files/rename.tsx';
import * as $api_files_search from './routes/api/files/search.tsx';
import * as $api_files_update_share from './routes/api/files/update-share.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_delete_feed from './routes/api/news/delete-feed.tsx';
import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
import * as $api_notes_save from './routes/api/notes/save.tsx';
import * as $caldav from './routes/caldav.tsx';
import * as $calendar from './routes/calendar.tsx';
import * as $calendar_calendarEventId_ from './routes/calendar/[calendarEventId].tsx';
import * as $calendars from './routes/calendars.tsx';
import * as $carddav from './routes/carddav.tsx';
import * as $contacts from './routes/contacts.tsx';
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
import * as $dashboard from './routes/dashboard.tsx';
import * as $dav from './routes/dav.tsx';
import * as $expenses from './routes/expenses.tsx';
import * as $file_share_fileShareId_ from './routes/file-share/[fileShareId].tsx';
import * as $file_share_fileShareId_open_fileName_ from './routes/file-share/[fileShareId]/open/[fileName].tsx';
import * as $file_share_fileShareId_verify from './routes/file-share/[fileShareId]/verify.tsx';
import * as $files from './routes/files.tsx';
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
import * as $index from './routes/index.tsx';
import * as $login from './routes/login.tsx';
import * as $logout from './routes/logout.tsx';
import * as $mfa_verify from './routes/mfa-verify.tsx';
import * as $news from './routes/news.tsx';
import * as $news_feeds from './routes/news/feeds.tsx';
import * as $notes from './routes/notes.tsx';
import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx';
import * as $oidc_callback from './routes/oidc/callback.tsx';
import * as $photos from './routes/photos.tsx';
import * as $photos_thumbnail_fileName_ from './routes/photos/thumbnail/[fileName].tsx';
import * as $settings from './routes/settings.tsx';
import * as $signup from './routes/signup.tsx';
import * as $Settings from './islands/Settings.tsx';
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx';
import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx';
import * as $calendar_Calendars from './islands/calendar/Calendars.tsx';
import * as $calendar_ViewCalendarEvent from './islands/calendar/ViewCalendarEvent.tsx';
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx';
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
import * as $news_Articles from './islands/news/Articles.tsx';
import * as $news_Feeds from './islands/news/Feeds.tsx';
import * as $notes_Note from './islands/notes/Note.tsx';
import * as $notes_NotesWrapper from './islands/notes/NotesWrapper.tsx';
import * as $photos_PhotosWrapper from './islands/photos/PhotosWrapper.tsx';
import type { Manifest } from '$fresh/server.ts';
const manifest = {
routes: {
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
'./routes/_404.tsx': $_404,
'./routes/_app.tsx': $_app,
'./routes/_middleware.tsx': $_middleware,
'./routes/api/auth/multi-factor/disable.ts': $api_auth_multi_factor_disable,
'./routes/api/auth/multi-factor/email/setup.ts': $api_auth_multi_factor_email_setup,
'./routes/api/auth/multi-factor/enable.ts': $api_auth_multi_factor_enable,
'./routes/api/auth/multi-factor/passkey/begin.ts': $api_auth_multi_factor_passkey_begin,
'./routes/api/auth/multi-factor/passkey/setup-begin.ts': $api_auth_multi_factor_passkey_setup_begin,
'./routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete,
'./routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify,
'./routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup,
'./routes/api/calendar/add-event.tsx': $api_calendar_add_event,
'./routes/api/calendar/add.tsx': $api_calendar_add,
'./routes/api/calendar/delete-event.tsx': $api_calendar_delete_event,
'./routes/api/calendar/delete.tsx': $api_calendar_delete,
'./routes/api/calendar/export-events.tsx': $api_calendar_export_events,
'./routes/api/calendar/import.tsx': $api_calendar_import,
'./routes/api/calendar/search-events.tsx': $api_calendar_search_events,
'./routes/api/calendar/update.tsx': $api_calendar_update,
'./routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook,
'./routes/api/contacts/add.tsx': $api_contacts_add,
'./routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook,
'./routes/api/contacts/delete.tsx': $api_contacts_delete,
'./routes/api/contacts/get-addressbooks.tsx': $api_contacts_get_addressbooks,
'./routes/api/contacts/get.tsx': $api_contacts_get,
'./routes/api/contacts/import.tsx': $api_contacts_import,
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
'./routes/api/expenses/add-budget.tsx': $api_expenses_add_budget,
'./routes/api/expenses/add-expense.tsx': $api_expenses_add_expense,
'./routes/api/expenses/auto-complete.tsx': $api_expenses_auto_complete,
'./routes/api/expenses/delete-budget.tsx': $api_expenses_delete_budget,
'./routes/api/expenses/delete-expense.tsx': $api_expenses_delete_expense,
'./routes/api/expenses/export-expenses.tsx': $api_expenses_export_expenses,
'./routes/api/expenses/import-expenses.tsx': $api_expenses_import_expenses,
'./routes/api/expenses/update-budget.tsx': $api_expenses_update_budget,
'./routes/api/expenses/update-expense.tsx': $api_expenses_update_expense,
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
'./routes/api/files/create-share.tsx': $api_files_create_share,
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
'./routes/api/files/delete-share.tsx': $api_files_delete_share,
'./routes/api/files/delete.tsx': $api_files_delete,
'./routes/api/files/get-directories.tsx': $api_files_get_directories,
'./routes/api/files/get-share.tsx': $api_files_get_share,
'./routes/api/files/get.tsx': $api_files_get,
'./routes/api/files/move-directory.tsx': $api_files_move_directory,
'./routes/api/files/move.tsx': $api_files_move,
'./routes/api/files/rename-directory.tsx': $api_files_rename_directory,
'./routes/api/files/rename.tsx': $api_files_rename,
'./routes/api/files/search.tsx': $api_files_search,
'./routes/api/files/update-share.tsx': $api_files_update_share,
'./routes/api/files/upload.tsx': $api_files_upload,
'./routes/api/news/add-feed.tsx': $api_news_add_feed,
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed,
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds,
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
'./routes/api/notes/save.tsx': $api_notes_save,
'./routes/caldav.tsx': $caldav,
'./routes/calendar.tsx': $calendar,
'./routes/calendar/[calendarEventId].tsx': $calendar_calendarEventId_,
'./routes/calendars.tsx': $calendars,
'./routes/carddav.tsx': $carddav,
'./routes/contacts.tsx': $contacts,
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
'./routes/dashboard.tsx': $dashboard,
'./routes/dav.tsx': $dav,
'./routes/expenses.tsx': $expenses,
'./routes/file-share/[fileShareId].tsx': $file_share_fileShareId_,
'./routes/file-share/[fileShareId]/open/[fileName].tsx': $file_share_fileShareId_open_fileName_,
'./routes/file-share/[fileShareId]/verify.tsx': $file_share_fileShareId_verify,
'./routes/files.tsx': $files,
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
'./routes/index.tsx': $index,
'./routes/login.tsx': $login,
'./routes/logout.tsx': $logout,
'./routes/mfa-verify.tsx': $mfa_verify,
'./routes/news.tsx': $news,
'./routes/news/feeds.tsx': $news_feeds,
'./routes/notes.tsx': $notes,
'./routes/notes/open/[fileName].tsx': $notes_open_fileName_,
'./routes/oidc/callback.tsx': $oidc_callback,
'./routes/photos.tsx': $photos,
'./routes/photos/thumbnail/[fileName].tsx': $photos_thumbnail_fileName_,
'./routes/settings.tsx': $settings,
'./routes/signup.tsx': $signup,
},
islands: {
'./islands/Settings.tsx': $Settings,
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin,
'./islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper,
'./islands/calendar/Calendars.tsx': $calendar_Calendars,
'./islands/calendar/ViewCalendarEvent.tsx': $calendar_ViewCalendarEvent,
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
'./islands/dashboard/Links.tsx': $dashboard_Links,
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
'./islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper,
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
'./islands/news/Articles.tsx': $news_Articles,
'./islands/news/Feeds.tsx': $news_Feeds,
'./islands/notes/Note.tsx': $notes_Note,
'./islands/notes/NotesWrapper.tsx': $notes_NotesWrapper,
'./islands/photos/PhotosWrapper.tsx': $photos_PhotosWrapper,
},
baseUrl: import.meta.url,
} satisfies Manifest;
export default manifest;

View file

@ -1,290 +0,0 @@
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
import { convertObjectToFormData } from '/lib/utils/misc.ts';
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
import { getTimeZones } from '/lib/utils/calendar.ts';
interface SettingsProps {
formData: Record<string, any>;
error?: {
title: string;
message: string;
};
notice?: {
title: string;
message: string;
};
currency?: SupportedCurrencySymbol;
timezoneId?: string;
isExpensesAppEnabled: boolean;
isMultiFactorAuthEnabled: boolean;
isCalendarAppEnabled: boolean;
helpEmail: string;
user: {
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
};
}
export type Action =
| 'change-email'
| 'verify-change-email'
| 'change-password'
| 'change-dav-password'
| 'delete-account'
| 'change-currency'
| 'change-timezone';
export const actionWords = new Map<Action, string>([
['change-email', 'change email'],
['verify-change-email', 'change email'],
['change-password', 'change password'],
['change-dav-password', 'change WebDav password'],
['delete-account', 'delete account'],
['change-currency', 'change currency'],
['change-timezone', 'change timezone'],
]);
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
const fields: FormField[] = [
{
name: 'action',
label: '',
type: 'hidden',
value: action,
overrideValue: action,
required: true,
readOnly: true,
},
];
if (action === 'change-email') {
fields.push({
name: 'email',
label: 'Email',
type: 'email',
placeholder: 'jane.doe@example.com',
value: getFormDataField(formData, 'email'),
required: true,
});
} else if (action === 'verify-change-email') {
fields.push({
name: 'email',
label: 'Email',
type: 'email',
placeholder: 'jane.doe@example.com',
value: getFormDataField(formData, 'email'),
required: true,
}, {
name: 'verification-code',
label: 'Verification Code',
description: `The verification code to validate your new email.`,
type: 'text',
placeholder: '000000',
required: true,
});
} else if (action === 'change-password') {
fields.push({
name: 'current-password',
label: 'Current Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
required: true,
}, {
name: 'new-password',
label: 'New Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
required: true,
});
} else if (action === 'change-dav-password') {
fields.push({
name: 'new-dav-password',
label: 'New WebDav Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
required: true,
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
});
} else if (action === 'delete-account') {
fields.push({
name: 'current-password',
label: 'Password',
type: 'password',
placeholder: 'super-SECRET-passphrase',
description: 'You need to input your password in order to delete your account.',
required: true,
});
} else if (action === 'change-currency') {
fields.push({
name: 'currency',
label: 'Currency',
type: 'select',
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
value: currencySymbol,
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
})),
value: getFormDataField(formData, 'currency') || currency,
required: true,
});
} else if (action === 'change-timezone') {
const timezones = getTimeZones();
fields.push({
name: 'timezone',
label: 'Timezone',
type: 'select',
options: timezones.map((timezone) => ({
value: timezone.id,
label: timezone.label,
})),
value: getFormDataField(formData, 'timezone') || timezoneId,
required: true,
});
}
return fields;
}
export default function Settings(
{
formData: formDataObject,
error,
notice,
currency,
timezoneId,
isExpensesAppEnabled,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
helpEmail,
user,
}: SettingsProps,
) {
const formData = convertObjectToFormData(formDataObject);
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
return (
<>
<section class='mx-auto max-w-7xl my-8'>
{error
? (
<section class='notification-error'>
<h3>{error.title}</h3>
<p>{error.message}</p>
</section>
)
: null}
{notice
? (
<section class='notification-success'>
<h3>{notice.title}</h3>
<p>{notice.message}</p>
</section>
)
: null}
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your email</h2>
<form method='POST' class='mb-12'>
{formFields(
'change-email',
formData,
).map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change email</button>
</section>
</form>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your password</h2>
<form method='POST' class='mb-12'>
{formFields('change-password', formData).map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change password</button>
</section>
</form>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your WebDav password</h2>
<form method='POST' class='mb-12'>
{formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change WebDav password</button>
</section>
</form>
{isExpensesAppEnabled
? (
<>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your currency</h2>
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
</p>
<form method='POST' class='mb-12'>
{formFields('change-currency', formData, currency, timezoneId).map((field) =>
generateFieldHtml(field, formData)
)}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change currency</button>
</section>
</form>
</>
)
: null}
{isCalendarAppEnabled
? (
<>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your timezone</h2>
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
This is only used in the calendar app.
</p>
<form method='POST' class='mb-12'>
{formFields('change-timezone', formData, currency, timezoneId).map((field) =>
generateFieldHtml(field, formData)
)}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change timezone</button>
</section>
</form>
</>
)
: null}
{isMultiFactorAuthEnabled
? (
<MultiFactorAuthSettings
methods={multiFactorAuthMethods.map((method) => ({
type: method.type,
id: method.id,
name: method.name,
enabled: method.enabled,
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
}))}
/>
)
: null}
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
Deleting your account is instant and deletes all your data. {helpEmail !== ''
? (
<>
If you need help, please <a href={`mailto:${helpEmail}`}>reach out</a>.
</>
)
: null}
</p>
<form method='POST' class='mb-12'>
{formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-danger' type='submit'>Delete account</button>
</section>
</form>
</section>
</>
);
}

View file

@ -1,116 +0,0 @@
import { useSignal } from '@preact/signals';
import { startAuthentication } from '@simplewebauthn/browser';
import {
RequestBody as PasskeyBeginRequestBody,
ResponseBody as PasskeyBeginResponseBody,
} from '/routes/api/auth/multi-factor/passkey/begin.ts';
import {
RequestBody as PasskeyVerifyRequestBody,
ResponseBody as PasskeyVerifyResponseBody,
} from '/routes/api/auth/multi-factor/passkey/verify.ts';
interface PasswordlessPasskeyLoginProps {
email?: string;
redirectUrl?: string;
}
export default function PasswordlessPasskeyLogin({ email: providedEmail, redirectUrl }: PasswordlessPasskeyLoginProps) {
const isLoading = useSignal(false);
const email = useSignal<string | null>(providedEmail || null);
const error = useSignal<string | null>(null);
const handlePasswordlessLogin = async () => {
if (isLoading.value) {
return;
}
if (!email.value) {
const promptEmail = prompt('Please enter your email');
if (!promptEmail) {
throw new Error('Email is required to login with Passkey');
}
email.value = promptEmail;
}
isLoading.value = true;
error.value = null;
try {
const beginRequestBody: PasskeyBeginRequestBody = {
email: email.value,
};
const beginResponse = await fetch('/api/auth/multi-factor/passkey/begin', {
method: 'POST',
body: JSON.stringify(beginRequestBody),
});
if (!beginResponse.ok) {
throw new Error(
`Failed to begin passwordless login! ${beginResponse.statusText} ${await beginResponse.text()}`,
);
}
const beginData = await beginResponse.json() as PasskeyBeginResponseBody;
if (!beginData.success) {
throw new Error(beginData.error || 'Failed to begin passwordless login');
}
const authenticationResponse = await startAuthentication({
optionsJSON: beginData.options!,
});
const verifyRequestBody: PasskeyVerifyRequestBody = {
email: email.value,
challenge: beginData.sessionData!.challenge,
authenticationResponse,
redirectUrl: redirectUrl || '/',
};
const verifyResponse = await fetch('/api/auth/multi-factor/passkey/verify', {
method: 'POST',
body: JSON.stringify(verifyRequestBody),
});
if (verifyResponse.ok) {
window.location.href = redirectUrl || '/';
return;
}
const verifyData = await verifyResponse.json() as PasskeyVerifyResponseBody;
throw new Error(
verifyData.error || `Authentication failed! ${verifyResponse.statusText} ${await verifyResponse.text()}`,
);
} catch (handleError) {
console.error('Passwordless passkey login failed:', handleError);
error.value = (handleError as Error).message;
} finally {
isLoading.value = false;
}
};
return (
<section class='space-y-4'>
<section class='flex justify-center mt-2 mb-4'>
<button
type='button'
onClick={handlePasswordlessLogin}
class='button-secondary'
>
{isLoading.value ? 'Authenticating...' : 'Login with Passkey'}
</button>
</section>
{error.value
? (
<section class='notification-error'>
<p>{error.value}</p>
</section>
)
: null}
</section>
);
}

View file

@ -1,30 +0,0 @@
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import MainCalendar from '/components/calendar/MainCalendar.tsx';
interface CalendarWrapperProps {
initialCalendars: Calendar[];
initialCalendarEvents: CalendarEvent[];
view: 'day' | 'week' | 'month';
startDate: string;
baseUrl: string;
timezoneId: string;
timezoneUtcOffset: number;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function CalendarWrapper(
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }:
CalendarWrapperProps,
) {
return (
<MainCalendar
initialCalendars={initialCalendars}
initialCalendarEvents={initialCalendarEvents}
view={view}
startDate={startDate}
baseUrl={baseUrl}
timezoneId={timezoneId}
timezoneUtcOffset={timezoneUtcOffset}
/>
);
}

View file

@ -1,23 +0,0 @@
import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
import MainExpenses from '/components/expenses/MainExpenses.tsx';
interface ExpensesWrapperProps {
initialBudgets: Budget[];
initialExpenses: Expense[];
initialMonth: string;
currency: SupportedCurrencySymbol;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function ExpensesWrapper(
{ initialBudgets, initialExpenses, initialMonth, currency }: ExpensesWrapperProps,
) {
return (
<MainExpenses
initialBudgets={initialBudgets}
initialExpenses={initialExpenses}
initialMonth={initialMonth}
currency={currency}
/>
);
}

View file

@ -1,34 +0,0 @@
import { Directory, DirectoryFile } from '/lib/types.ts';
import MainFiles from '/components/files/MainFiles.tsx';
interface FilesWrapperProps {
initialDirectories: Directory[];
initialFiles: DirectoryFile[];
initialPath: string;
baseUrl: string;
isFileSharingAllowed: boolean;
fileShareId?: string;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function FilesWrapper(
{
initialDirectories,
initialFiles,
initialPath,
baseUrl,
isFileSharingAllowed,
fileShareId,
}: FilesWrapperProps,
) {
return (
<MainFiles
initialDirectories={initialDirectories}
initialFiles={initialFiles}
initialPath={initialPath}
baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed}
fileShareId={fileShareId}
/>
);
}

View file

@ -1,21 +0,0 @@
import { Directory, DirectoryFile } from '/lib/types.ts';
import MainNotes from '/components/notes/MainNotes.tsx';
interface NotesWrapperProps {
initialDirectories: Directory[];
initialFiles: DirectoryFile[];
initialPath: string;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function NotesWrapper(
{ initialDirectories, initialFiles, initialPath }: NotesWrapperProps,
) {
return (
<MainNotes
initialDirectories={initialDirectories}
initialFiles={initialFiles}
initialPath={initialPath}
/>
);
}

View file

@ -1,21 +0,0 @@
import { Directory, DirectoryFile } from '/lib/types.ts';
import MainPhotos from '/components/photos/MainPhotos.tsx';
interface PhotosWrapperProps {
initialDirectories: Directory[];
initialFiles: DirectoryFile[];
initialPath: string;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function PhotosWrapper(
{ initialDirectories, initialFiles, initialPath }: PhotosWrapperProps,
) {
return (
<MainPhotos
initialDirectories={initialDirectories}
initialFiles={initialFiles}
initialPath={initialPath}
/>
);
}

View file

@ -1,12 +1,11 @@
import { decodeBase64Url, encodeBase64Url } from 'std/encoding/base64url.ts'; import { decodeBase64, decodeBase64Url, encodeBase64Url } from '@std/encoding';
import { decodeBase64 } from 'std/encoding/base64.ts'; import { Cookie, getCookies, setCookie } from '@std/http';
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import '@std/dotenv/load';
import 'std/dotenv/load.ts';
import { generateHash, isRunningLocally } from './utils/misc.ts'; import { generateHash, isRunningLocally } from '/public/ts/utils/misc.ts';
import { User, UserSession } from './types.ts'; import { User, UserSession } from '/lib/types.ts';
import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts'; import { UserModel, UserSessionModel, validateUserAndSession } from '/lib/models/user.ts';
import { AppConfig } from './config.ts'; import { AppConfig } from '/lib/config.ts';
export const JWT_SECRET = Deno.env.get('JWT_SECRET') || ''; export const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || ''; export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';

View file

@ -1,3 +1,4 @@
import { isAbsolute, join } from '@std/path';
import { UserModel } from './models/user.ts'; import { UserModel } from './models/user.ts';
import { Config, OptionalApp } from './types.ts'; import { Config, OptionalApp } from './types.ts';
@ -15,6 +16,7 @@ export class AppConfig {
allowedCookieDomains: [], allowedCookieDomains: [],
skipCookieDomainSecurity: false, skipCookieDomainSecurity: false,
enableSingleSignOn: false, enableSingleSignOn: false,
allowSignupsViaSingleSignOn: false,
singleSignOnUrl: '', singleSignOnUrl: '',
singleSignOnEmailAttribute: 'email', singleSignOnEmailAttribute: 'email',
singleSignOnScopes: ['openid', 'email'], singleSignOnScopes: ['openid', 'email'],
@ -22,9 +24,12 @@ export class AppConfig {
files: { files: {
rootPath: 'data-files', rootPath: 'data-files',
allowPublicSharing: false, allowPublicSharing: false,
allowDirectoryDownloads: false,
maxUploadSizeInMegabytes: 100,
}, },
core: { core: {
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
maxRequestSizeInMegabytes: 12,
}, },
visuals: { visuals: {
title: '', title: '',
@ -35,6 +40,8 @@ export class AppConfig {
from: 'help@bewcloud.com', from: 'help@bewcloud.com',
host: 'localhost', host: 'localhost',
port: 465, port: 465,
tlsMode: 'auto',
tlsVerify: true,
}, },
contacts: { contacts: {
enableCardDavServer: true, enableCardDavServer: true,
@ -95,6 +102,10 @@ export class AppConfig {
console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n'); console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n');
if (this.config.core.enabledApps.length === 0) {
throw new Error('At least one app must be enabled. Please check the config.core.enabledApps array.');
}
return; return;
} catch (error) { } catch (error) {
console.error('Error loading config from bewcloud.config.ts. Using default config instead.', error); console.error('Error loading config from bewcloud.config.ts. Using default config instead.', error);
@ -109,11 +120,12 @@ export class AppConfig {
return this.config; return this.config;
} }
static async isSignupAllowed(): Promise<boolean> { static async isSignupAllowed({ viaSingleSignOn = false }: { viaSingleSignOn?: boolean } = {}): Promise<boolean> {
await this.loadConfig(); await this.loadConfig();
const areSignupsAllowed = this.config.auth.allowSignups; const areSignupsAllowed = viaSingleSignOn && !this.config.auth.allowSignups
? this.config.auth.allowSignupsViaSingleSignOn
: this.config.auth.allowSignups;
const areThereAdmins = await UserModel.isThereAnAdmin(); const areThereAdmins = await UserModel.isThereAnAdmin();
if (areSignupsAllowed || !areThereAdmins) { if (areSignupsAllowed || !areThereAdmins) {
@ -179,12 +191,20 @@ export class AppConfig {
return this.config.files.allowPublicSharing; return this.config.files.allowPublicSharing;
} }
static async areDirectoryDownloadsAllowed(): Promise<boolean> {
await this.loadConfig();
return this.config.files.allowDirectoryDownloads;
}
static async getFilesRootPath(): Promise<string> { static async getFilesRootPath(): Promise<string> {
await this.loadConfig(); await this.loadConfig();
const filesRootPath = `${Deno.cwd()}/${this.config.files.rootPath}`; if (isAbsolute(this.config.files.rootPath)) {
return this.config.files.rootPath;
return filesRootPath; } else {
return join(Deno.cwd(), this.config.files.rootPath);
}
} }
static async getEmailConfig(): Promise<Config['email']> { static async getEmailConfig(): Promise<Config['email']> {

View file

@ -1,7 +1,8 @@
import { DOMParser, initParser } from 'https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm-noinit.ts'; import { DOMParser, initParser } from '@b-fuze/deno-dom/wasm-noinit';
import { Feed, parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; import { Feed, parseFeed } from '@mikaelporttila/rss';
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils/misc.ts';
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts'; import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from '/public/ts/utils/misc.ts';
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from '/lib/types.ts';
export interface JsonFeedItem { export interface JsonFeedItem {
id: string; id: string;
@ -221,13 +222,20 @@ export async function getUrlInfo(url: string): Promise<{ title: string; htmlBody
} }
export async function parseTextFromHtml(html: string): Promise<string> { export async function parseTextFromHtml(html: string): Promise<string> {
let text = ''; if (!html || !html.trim()) {
return '';
}
await initParser(); await initParser();
const document = new DOMParser().parseFromString(html, 'text/html'); const document = new DOMParser().parseFromString(html, 'text/html');
text = document!.textContent; // Extract text from body to avoid any artifacts from the document wrapper
const text = (document?.querySelector('body')?.textContent || document?.textContent || '')
// Collapse runs of 2+ whitespace/newline characters, preserving single line breaks
.replace(/[^\S\n]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
return text; return text;
} }

View file

@ -1,186 +0,0 @@
export interface FormField {
name: string;
label: string;
value?: string | null;
overrideValue?: string;
description?: string;
placeholder?: string;
type:
| 'text'
| 'email'
| 'tel'
| 'url'
| 'date'
| 'datetime-local'
| 'number'
| 'range'
| 'select'
| 'textarea'
| 'checkbox'
| 'hidden'
| 'password';
step?: string;
max?: string;
min?: string;
rows?: string;
options?: {
label: string;
value: string;
}[];
checked?: boolean;
multiple?: boolean;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
extraClasses?: string;
}
export function getFormDataField(formData: FormData, field: string) {
return ((formData.get(field) || '') as string).trim();
}
export function getFormDataFieldArray(formData: FormData, field: string) {
return ((formData.getAll(field) || []) as string[]).map((value) => value.trim());
}
export function generateFieldHtml(
field: FormField,
formData: FormData,
) {
let value = field.overrideValue ||
(field.multiple ? getFormDataFieldArray(formData, field.name) : getFormDataField(formData, field.name)) ||
field.value;
if (typeof field.overrideValue !== 'undefined') {
value = field.overrideValue;
}
if (field.type === 'hidden') {
return generateInputHtml(field, value);
}
return (
<fieldset class={`block mb-4 ${field.extraClasses || ''}`}>
<label class='text-slate-300 block pb-1' for={`field_${field.name}`}>{field.label}</label>
{generateInputHtml(field, value)}
{field.description
? (
<aside class={`text-sm text-slate-400 p-2 ${field.type === 'checkbox' ? 'inline' : ''}`}>
{field.description}
</aside>
)
: null}
</fieldset>
);
}
function generateInputHtml(
{
name,
placeholder,
type,
options,
step,
max,
min,
rows,
checked,
multiple,
disabled,
required,
readOnly,
}: FormField,
value?: string | string[] | null,
) {
const additionalAttributes: Record<string, string | number | boolean> = {};
if (typeof step !== 'undefined') {
additionalAttributes.step = parseInt(step, 10);
}
if (typeof max !== 'undefined') {
additionalAttributes.max = parseInt(max, 10);
}
if (typeof min !== 'undefined') {
additionalAttributes.min = parseInt(min, 10);
}
if (typeof rows !== 'undefined') {
additionalAttributes.rows = parseInt(rows, 10);
}
if (checked === true && type === 'checkbox' && value) {
additionalAttributes.checked = true;
}
if (multiple === true) {
additionalAttributes.multiple = true;
}
if (required === true) {
additionalAttributes.required = true;
}
if (disabled === true) {
additionalAttributes.disabled = true;
}
if (readOnly === true) {
additionalAttributes.readonly = true;
}
if (type === 'select') {
return (
<select class='mt-1 input-field' id={`field_${name}`} name={name} type={type} {...additionalAttributes}>
{options?.map((option) => (
<option
value={option.value}
selected={option.value === value || (multiple && (value || [])?.includes(option.value))}
>
{option.label}
</option>
))}
</select>
);
}
if (type === 'textarea') {
return (
<textarea
class='mt-1 input-field'
id={`field_${name}`}
name={name}
rows={6}
placeholder={placeholder}
{...additionalAttributes}
>
{(value as string) || ''}
</textarea>
);
}
if (type === 'checkbox') {
return (
<input id={`field_${name}`} name={name} type={type} value={value as string || ''} {...additionalAttributes} />
);
}
if (type === 'password') {
return (
<input
class='mt-1 input-field'
id={`field_${name}`}
name={name}
type={type}
placeholder={placeholder || ''}
value=''
{...additionalAttributes}
/>
);
}
return (
<input
class='mt-1 input-field'
id={`field_${name}`}
name={name}
type={type}
placeholder={placeholder || ''}
value={value as string || ''}
{...additionalAttributes}
/>
);
}

View file

@ -1,5 +1,5 @@
import { Client } from 'postgres'; import { Client } from 'postgres';
import 'std/dotenv/load.ts'; import '@std/dotenv/load';
const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || ''; const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || '';
const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || ''; const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || '';

View file

@ -1,8 +1,7 @@
import { createDAVClient } from 'tsdav'; import { createDAVClient } from '/lib/models/dav.js';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { getColorAsHex, parseVCalendar } from '/lib/utils/calendar.ts'; import { getColorAsHex, parseVCalendar } from '/public/ts/utils/calendar.ts';
import { concurrentPromises } from '/lib/utils/misc.ts'; import { concurrentPromises } from '/public/ts/utils/misc.ts';
import { UserModel } from '/lib/models/user.ts'; import { UserModel } from '/lib/models/user.ts';
interface DAVObject extends Record<string, any> { interface DAVObject extends Record<string, any> {
@ -146,7 +145,7 @@ export class CalendarModel {
displayName: string, displayName: string,
color?: string, color?: string,
): Promise<void> { ): Promise<void> {
// Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because tsdav doesn't have PROPPATCH // Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because the dav client doesn't have PROPPATCH
const xmlBody = `<?xml version="1.0" encoding="utf-8"?> const xmlBody = `<?xml version="1.0" encoding="utf-8"?>
<d:proppatch xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/"> <d:proppatch xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
<d:set> <d:set>
@ -195,15 +194,25 @@ export class CalendarEventModel {
}, },
}; };
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions);
if (dateRange) { if (dateRange) {
fetchOptions.timeRange = { fetchOptions.timeRange = {
start: dateRange.start.toISOString(), start: dateRange.start.toISOString(),
end: dateRange.end.toISOString(), end: dateRange.end.toISOString(),
}; };
fetchOptions.expand = true; fetchOptions.expand = true;
}
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions); // Sometimes the expand option doesn't return anything, so we we fetch with and without it, when queried for a date range
const davCalendarEventsWithExpansion = await client.fetchCalendarObjects(fetchOptions);
for (const davCalendarEvent of davCalendarEventsWithExpansion) {
// Only add the events that are not already in the list
if (!davCalendarEvents.some((davCalendarEvent) => davCalendarEvent.url === davCalendarEvent.url)) {
davCalendarEvents.push(davCalendarEvent);
}
}
}
const calendarEvents: CalendarEvent[] = []; const calendarEvents: CalendarEvent[] = [];

View file

@ -1,7 +1,6 @@
import { createDAVClient } from 'tsdav'; import { createDAVClient } from '/lib/models/dav.js';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { parseVCard } from '/lib/utils/contacts.ts'; import { parseVCard } from '/public/ts/utils/contacts.ts';
interface DAVObject extends Record<string, any> { interface DAVObject extends Record<string, any> {
data?: string; data?: string;

10891
lib/models/dav.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import 'std/dotenv/load.ts'; import '@std/dotenv/load';
import { escapeHtml } from '/lib/utils/misc.ts'; import { escapeHtml } from '/public/ts/utils/misc.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || ''; const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || '';
@ -15,14 +15,30 @@ export class EmailModel {
throw new Error('config.email.from, config.email.host, or config.email.port is not set'); throw new Error('config.email.from, config.email.host, or config.email.port is not set');
} }
let tlsMode = emailConfig.tlsMode;
if (tlsMode === 'auto') {
tlsMode = Number(emailConfig.port) === 465 ? 'immediate' : 'starttls';
}
const transporterConfig = { const transporterConfig = {
host: emailConfig.host, host: emailConfig.host,
port: emailConfig.port, port: emailConfig.port,
secure: Number(emailConfig.port) === 465,
auth: { secure: tlsMode === 'immediate',
requireTLS: tlsMode === 'starttls',
ignoreTLS: tlsMode === 'none',
tls: emailConfig.tlsVerify === false
? { rejectUnauthorized: false }
: emailConfig.tlsVerify !== true
? { servername: emailConfig.tlsVerify }
: {},
auth: (SMTP_USERNAME || SMTP_PASSWORD)
? {
user: SMTP_USERNAME, user: SMTP_USERNAME,
pass: SMTP_PASSWORD, pass: SMTP_PASSWORD,
}, }
: null,
}; };
const transporter = nodemailer.createTransport(transporterConfig); const transporter = nodemailer.createTransport(transporterConfig);
@ -59,7 +75,6 @@ export class EmailModel {
<style type="text/css" rel="stylesheet" media="all"> <style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */ /* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body { body {
width: 100% !important; width: 100% !important;
height: 100%; height: 100%;
@ -95,7 +110,8 @@ export class EmailModel {
body, body,
td, td,
th { th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif; /* Source: https://fontsarena.com/blog/operating-systems-default-sans-serif-fonts/ */
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", Oxygen, Cantarell, sans-serif;
} }
h1 { h1 {

View file

@ -1,11 +1,16 @@
import { join } from 'std/path/join.ts'; import { join, resolve } from '@std/path';
import { resolve } from 'std/path/resolve.ts';
import { lookup } from 'mrmime'; import { lookup } from 'mrmime';
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import { Cookie, getCookies, setCookie } from '@std/http';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts'; import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; import {
bytesFromHumanFileSize,
sortDirectoriesByName,
sortEntriesByName,
sortFilesByName,
TRASH_PATH,
} from '/public/ts/utils/files.ts';
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';
import { import {
COOKIE_NAME as AUTH_COOKIE_NAME, COOKIE_NAME as AUTH_COOKIE_NAME,
@ -15,7 +20,7 @@ import {
resolveCookieDomain, resolveCookieDomain,
verifyAuthJwt, verifyAuthJwt,
} from '/lib/auth.ts'; } from '/lib/auth.ts';
import { isRunningLocally } from '/lib/utils/misc.ts'; import { isRunningLocally } from '/public/ts/utils/misc.ts';
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`; const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
@ -40,12 +45,14 @@ export class DirectoryModel {
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 directorySize = await getDirectorySize(join(rootPath, entry.name));
const directory: Directory = { const directory: Directory = {
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,
size_in_bytes: stat.size, size_in_bytes: directorySize || stat.size,
file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null, file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null,
updated_at: stat.mtime || new Date(), updated_at: stat.mtime || new Date(),
created_at: stat.birthtime || new Date(), created_at: stat.birthtime || new Date(),
@ -142,12 +149,14 @@ export class DirectoryModel {
parentPath = '/'; parentPath = '/';
} }
const directorySize = await getDirectorySize(join(rootPath, relativeDirectoryPath));
const directory: Directory = { const directory: Directory = {
user_id: userId, user_id: userId,
parent_path: parentPath, parent_path: parentPath,
directory_name: directoryName, directory_name: directoryName,
has_write_access: true, has_write_access: true,
size_in_bytes: stat.size, size_in_bytes: directorySize || stat.size,
file_share_id: fileShares.find((fileShare) => file_share_id: fileShares.find((fileShare) =>
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/` fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
)?.id || null, )?.id || null,
@ -599,7 +608,11 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string,
const resolvedFullPath = `${resolve(fullPath)}/`; const resolvedFullPath = `${resolve(fullPath)}/`;
if (!resolvedFullPath.startsWith(userRootPath)) { // Normalize path separators for consistent comparison on Windows
const normalizedUserRootPath = userRootPath.replaceAll('\\', '/');
const normalizedResolvedFullPath = resolvedFullPath.replaceAll('\\', '/');
if (!normalizedResolvedFullPath.startsWith(normalizedUserRootPath)) {
throw new Error('Invalid file path'); throw new Error('Invalid file path');
} }
} }
@ -698,6 +711,14 @@ async function deleteDirectoryOrFile(userId: string, path: string, name: string)
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); 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 { try {
if (path.startsWith(TRASH_PATH)) { if (path.startsWith(TRASH_PATH)) {
await Deno.remove(join(rootPath, name), { recursive: true }); await Deno.remove(join(rootPath, name), { recursive: true });
@ -705,6 +726,11 @@ async function deleteDirectoryOrFile(userId: string, path: string, name: string)
const trashPath = join(await AppConfig.getFilesRootPath(), userId, TRASH_PATH); const trashPath = join(await AppConfig.getFilesRootPath(), userId, TRASH_PATH);
await Deno.rename(join(rootPath, name), join(trashPath, name)); 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) { } catch (error) {
console.error(error); console.error(error);
return false; return false;
@ -748,3 +774,45 @@ export async function getPathInfo(userId: string, path: string): Promise<{ isDir
isFile: stat.isFile, isFile: stat.isFile,
}; };
} }
// NOTE: We're using `-h` (human readable) and parsing the output because that's more stable than `-B 1B` across different systems for a reliable byte size.
async function getDirectorySize(path: string): Promise<number> {
try {
const controller = new AbortController();
const commandTimeout = setTimeout(() => controller.abort(), 5_000);
const command = new Deno.Command(`du`, {
args: [
`-sh`,
path,
],
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 "du"`);
}
const output = new TextDecoder().decode(stdout);
const value = output.split('\t')[0].trim();
const number = Number.parseFloat(value.match(/\d+(\.\d+)?/)?.[0] || '0');
const unit = value.match(/[A-Z]+/)?.[0] || 'B'.toUpperCase();
return bytesFromHumanFileSize(`${number} ${unit}B`);
} catch (error) {
console.error(error);
return 0;
}
}

View file

@ -1,10 +1,10 @@
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import { Cookie, getCookies, setCookie } from '@std/http';
import { MultiFactorAuthMethod, User } from '/lib/types.ts'; import { MultiFactorAuthMethod, User } from '/lib/types.ts';
import { import {
getEnabledMultiFactorAuthMethodsFromUser, getEnabledMultiFactorAuthMethodsFromUser,
getMultiFactorAuthMethodByIdFromUser, getMultiFactorAuthMethodByIdFromUser,
} from '/lib/utils/multi-factor-auth.ts'; } from '/public/ts/utils/multi-factor-auth.ts';
import { import {
COOKIE_NAME as AUTH_COOKIE_NAME, COOKIE_NAME as AUTH_COOKIE_NAME,
generateKey, generateKey,
@ -14,7 +14,7 @@ import {
resolveCookieDomain, resolveCookieDomain,
verifyAuthJwt, verifyAuthJwt,
} from '/lib/auth.ts'; } from '/lib/auth.ts';
import { isRunningLocally } from '/lib/utils/misc.ts'; import { isRunningLocally } from '/public/ts/utils/misc.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { UserModel } from './user.ts'; import { UserModel } from './user.ts';
import { EmailModel } from './multi-factor-auth/email.ts'; import { EmailModel } from './multi-factor-auth/email.ts';

View file

@ -1,11 +1,10 @@
import { Secret, TOTP } from 'otpauth'; import { Secret, TOTP } from 'otpauth';
import QRCode from 'qrcode'; import { qrcode } from '@libs/qrcode';
import { encodeBase32 } from 'std/encoding/base32.ts'; import { decodeBase64, encodeBase32, encodeBase64 } from '@std/encoding';
import { decodeBase64, encodeBase64 } from 'std/encoding/base64.ts';
import { MultiFactorAuthMethod } from '/lib/types.ts'; import { MultiFactorAuthMethod } from '/lib/types.ts';
import { MFA_KEY, MFA_SALT } from '/lib/auth.ts'; import { MFA_KEY, MFA_SALT } from '/lib/auth.ts';
import { generateHash } from '/lib/utils/misc.ts'; import { generateHash } from '/public/ts/utils/misc.ts';
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts'; import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
export class TOTPModel { export class TOTPModel {
@ -133,7 +132,8 @@ export class TOTPModel {
private static async generateQRCodeDataURL(secret: string, issuer: string, accountName: string): Promise<string> { private static async generateQRCodeDataURL(secret: string, issuer: string, accountName: string): Promise<string> {
const totp = this.createTOTP(secret, issuer, accountName); const totp = this.createTOTP(secret, issuer, accountName);
const uri = totp.toString(); const uri = totp.toString();
return await QRCode.toDataURL(uri); const svgString = await qrcode(uri, { output: 'svg', border: 0 });
return `data:image/svg+xml;base64,${encodeBase64(svgString)}`;
} }
private static verifyTOTPToken(secret: string, token: string, window = 1): boolean { private static verifyTOTPToken(secret: string, token: string, window = 1): boolean {

View file

@ -1,4 +1,4 @@
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; import { Feed } from '@mikaelporttila/rss';
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';
import Locker from '/lib/interfaces/locker.ts'; import Locker from '/lib/interfaces/locker.ts';
@ -136,7 +136,7 @@ export class FeedModel {
feedArticle.id; feedArticle.id;
// Fix relative URLs in the feeds // Fix relative URLs in the feeds
if (url.startsWith('/')) { if (url!.startsWith('/')) {
const feedUrl = new URL(newsFeed.feed_url); const feedUrl = new URL(newsFeed.feed_url);
url = `${feedUrl.origin}${url}`; url = `${feedUrl.origin}${url}`;
} }
@ -214,6 +214,17 @@ export class ArticleModel {
return articles; return articles;
} }
static async listUnread(userId: string) {
const articles = await db.query<NewsFeedArticle>(
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 AND "is_read" = FALSE ORDER BY "article_date" DESC`,
[
userId,
],
);
return articles;
}
static async listByFeedId(feedId: string) { static async listByFeedId(feedId: string) {
const articles = await db.query<NewsFeedArticle>( const articles = await db.query<NewsFeedArticle>(
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`, sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`,

View file

@ -1,10 +1,10 @@
import { decodeBase64Url } from 'std/encoding/base64url.ts'; import { decodeBase64Url } from '@std/encoding';
import * as openIdClient from 'openid-client'; import * as openIdClient from 'openid-client';
import 'std/dotenv/load.ts'; import '@std/dotenv/load';
import { createSessionResponse, dataToText } from '/lib/auth.ts'; import { createSessionResponse, dataToText } from '/lib/auth.ts';
import { UserModel } from '/lib/models/user.ts'; import { UserModel } from '/lib/models/user.ts';
import { generateRandomCode } from '/lib/utils/misc.ts'; import { generateRandomCode } from '/public/ts/utils/misc.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import SimpleCache from '/lib/interfaces/simple-cache.ts'; import SimpleCache from '/lib/interfaces/simple-cache.ts';
@ -169,7 +169,7 @@ export class OidcModel {
throw new Error(`Missing user/${emailAttribute}`); throw new Error(`Missing user/${emailAttribute}`);
} }
const isSignupAllowed = await AppConfig.isSignupAllowed(); const isSignupAllowed = await AppConfig.isSignupAllowed({ viaSingleSignOn: true });
const isThereAnAdmin = await UserModel.isThereAnAdmin(); const isThereAnAdmin = await UserModel.isThereAnAdmin();
// Confirm the user exists (or signup if allowed) // Confirm the user exists (or signup if allowed)
@ -181,10 +181,16 @@ export class OidcModel {
} }
if (!user) { if (!user) {
if (!config.auth.allowSignupsViaSingleSignOn) {
throw new Error('Sign up via SSO is not allowed!');
}
throw new Error('There was a problem signing up or logging in!'); throw new Error('There was a problem signing up or logging in!');
} }
let urlToRedirectTo = '/dashboard'; const firstEnabledApp = config.core.enabledApps[0];
let urlToRedirectTo = `/${firstEnabledApp}`;
if (urlSearchParams.has('state')) { if (urlSearchParams.has('state')) {
const state = this.parseState(urlSearchParams.get('state')!); const state = this.parseState(urlSearchParams.get('state')!);

View file

@ -1,6 +1,6 @@
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';
import { User, UserSession, VerificationCode } from '/lib/types.ts'; import { User, UserSession, VerificationCode } from '/lib/types.ts';
import { generateRandomCode } from '/lib/utils/misc.ts'; import { generateRandomCode } from '/public/ts/utils/misc.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
const db = new Database(); const db = new Database();

69
lib/page.ts Normal file
View file

@ -0,0 +1,69 @@
import { User, UserSession } from './types.ts';
import { JwtData } from './auth.ts';
export type RequestHandlerParams = {
request: Request;
match: URLPatternResult;
user?: User | null;
session?: { userSession?: UserSession; tokenData?: JwtData['data'] } | null;
isRunningLocally: boolean;
};
export type RequestHandler<T = Response> = (params: RequestHandlerParams) => T | Promise<T>;
export interface Page {
get?: RequestHandler;
post?: RequestHandler;
put?: RequestHandler;
patch?: RequestHandler;
delete?: RequestHandler;
options?: RequestHandler;
catchAll?: RequestHandler;
}
type AccessMode = 'public' | 'user';
interface PermissionsParams {
accessMode: AccessMode;
}
type Params = Page & PermissionsParams;
function permissioned(handler: RequestHandler, accessMode: AccessMode) {
return ({ request, match, user, session, isRunningLocally }: RequestHandlerParams) => {
if (accessMode !== 'public') {
if (!user) {
const url = new URL(request.url);
const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
return new Response('Redirect', { status: 302, headers: { 'Location': `/login?redirectTo=${redirectTo}` } });
}
}
if (!handler) {
return new Response('Not Implemented', { status: 501 });
}
return handler({ request, match, user, session, isRunningLocally });
};
}
export default function page(
{
get,
post,
put,
patch,
delete: deleteAction,
options,
catchAll,
accessMode,
}: Params,
): Page {
return {
get: get ? permissioned(get, accessMode) : undefined,
post: post ? permissioned(post, accessMode) : undefined,
put: put ? permissioned(put, accessMode) : undefined,
patch: patch ? permissioned(patch, accessMode) : undefined,
delete: deleteAction ? permissioned(deleteAction, accessMode) : undefined,
options: options ? permissioned(options, accessMode) : undefined,
catchAll: catchAll ? permissioned(catchAll, accessMode) : undefined,
};
}

View file

@ -31,11 +31,6 @@ export interface UserSession {
created_at: Date; created_at: Date;
} }
export interface FreshContextState {
user?: User;
session?: UserSession;
}
export interface VerificationCode { export interface VerificationCode {
id: string; id: string;
user_id: string; user_id: string;
@ -140,23 +135,15 @@ export interface Expense {
} }
export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹'; export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹';
type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR'; export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR';
export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
['$', 'USD'],
['€', 'EUR'],
['£', 'GBP'],
['¥', 'JPY'],
['₹', 'INR'],
]);
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T; export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts' | 'calendar'; export type OptionalApp = 'dashboard' | 'files' | 'news' | 'notes' | 'photos' | 'expenses' | 'contacts' | 'calendar';
export interface Config { export interface Config {
auth: { auth: {
/** The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" */ /** The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (note authentication won't work without https:// except for localhost; SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback") */
baseUrl: string; baseUrl: string;
/** If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin */ /** If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin */
allowSignups: boolean; allowSignups: boolean;
@ -172,6 +159,8 @@ export interface Config {
skipCookieDomainSecurity: boolean; skipCookieDomainSecurity: boolean;
/** If true, single sign-on will be enabled */ /** If true, single sign-on will be enabled */
enableSingleSignOn: boolean; enableSingleSignOn: boolean;
/** If true, signups via single sign-on will be allowed, overriding allowSignups */
allowSignupsViaSingleSignOn: boolean;
/** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */ /** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */
singleSignOnUrl: string; singleSignOnUrl: string;
/** The attribute to prefer as email of the identity/single sign-on provider */ /** The attribute to prefer as email of the identity/single sign-on provider */
@ -184,10 +173,16 @@ export interface Config {
rootPath: string; rootPath: string;
/** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */ /** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */
allowPublicSharing: boolean; allowPublicSharing: boolean;
/** If true, directories can be downloaded as zip files */
allowDirectoryDownloads: boolean;
/** The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints. */
maxUploadSizeInMegabytes: number;
}; };
core: { core: {
/** dashboard and files cannot be disabled */ /** The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. */
enabledApps: OptionalApp[]; enabledApps: OptionalApp[];
/** The maximum request size in megabytes. */
maxRequestSizeInMegabytes: number;
}; };
visuals: { visuals: {
/** An override title of the application. Empty shows the default title. */ /** An override title of the application. Empty shows the default title. */
@ -204,6 +199,10 @@ export interface Config {
host: string; host: string;
/** The SMTP port to send emails from */ /** The SMTP port to send emails from */
port: number; port: number;
/** "auto" means "immediate" on port 465, "starttls" otherwise. */
tlsMode: 'auto' | 'immediate' | 'starttls' | 'none';
/** Whether to verify the TLS certificate. If a string is used the hostname will be verified using that name. */
tlsVerify: boolean | string;
}; };
contacts: { contacts: {
/** If true, the CardDAV server will be enabled (proxied) */ /** If true, the CardDAV server will be enabled (proxied) */

View file

@ -1,503 +0,0 @@
import { assertEquals } from 'std/assert/assert_equals.ts';
import { assertMatch } from 'std/assert/assert_match.ts';
import { generateVCard, getIdFromVCard, parseVCard, splitTextIntoVCards, updateVCard } from './contacts.ts';
Deno.test('that getIdFromVCard works', () => {
const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [
{
input: `BEGIN:VCARD
VERSION:4.0
UID:12345-abcde-67890
FN:John Doe
END:VCARD`,
expected: '12345-abcde-67890',
},
{
input: `BEGIN:VCARD
VERSION:3.0
FN:Jane Smith
UID:jane-smith-uuid
EMAIL:jane@example.com
END:VCARD`,
expected: 'jane-smith-uuid',
},
{
input: `BEGIN:VCARD
VERSION:4.0
FN:No UID Contact
EMAIL:nouid@example.com
END:VCARD`,
shouldBeUUID: true,
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID: spaced-uid
FN:Spaced UID
END:VCARD`,
expected: 'spaced-uid',
},
];
for (const test of tests) {
const output = getIdFromVCard(test.input);
if (test.expected) {
assertEquals(output, test.expected);
} else if (test.shouldBeUUID) {
// Check that it's a valid UUID format
assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
}
}
});
Deno.test('that splitTextIntoVCards works', () => {
const tests: { input: string; expected: string[] }[] = [
{
input: `BEGIN:VCARD
VERSION:4.0
UID:1
FN:John Doe
END:VCARD
BEGIN:VCARD
VERSION:4.0
UID:2
FN:Jane Smith
END:VCARD`,
expected: [
`BEGIN:VCARD
VERSION:4.0
UID:1
FN:John Doe
END:VCARD`,
`BEGIN:VCARD
VERSION:4.0
UID:2
FN:Jane Smith
END:VCARD`,
],
},
{
input: `BEGIN:VCARD
VERSION:3.0
FN:Single Contact
EMAIL:single@example.com
END:VCARD`,
expected: [
`BEGIN:VCARD
VERSION:3.0
FN:Single Contact
EMAIL:single@example.com
END:VCARD`,
],
},
{
input: '',
expected: [],
},
{
input: `BEGIN:VCARD
VERSION:4.0
FN:Incomplete Contact`,
expected: [],
},
];
for (const test of tests) {
const output = splitTextIntoVCards(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that generateVCard works', () => {
const tests: { input: { contactId: string; firstName: string; lastName?: string }; expected: string }[] = [
{
input: { contactId: 'test-123', firstName: 'John', lastName: 'Doe' },
expected: `BEGIN:VCARD
VERSION:4.0
N:Doe;John;
FN:John Doe
UID:test-123
END:VCARD`,
},
{
input: { contactId: 'single-name', firstName: 'Madonna' },
expected: `BEGIN:VCARD
VERSION:4.0
N:;Madonna;
FN:Madonna
UID:single-name
END:VCARD`,
},
{
input: { contactId: 'special-chars', firstName: 'John,Test', lastName: 'Doe\nSmith' },
expected: `BEGIN:VCARD
VERSION:4.0
N:Doe\\nSmith;John\\,Test;
FN:John\\,Test Doe\\nSmith
UID:special-chars
END:VCARD`,
},
];
for (const test of tests) {
const output = generateVCard(test.input.contactId, test.input.firstName, test.input.lastName);
assertEquals(output, test.expected);
}
});
Deno.test('that updateVCard works', () => {
const tests: {
input: {
vCard: string;
updates: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
notes?: string;
};
};
expected: string;
}[] = [
{
input: {
vCard: `BEGIN:VCARD
VERSION:4.0
UID:test-123
N:Doe;John;
FN:John Doe
END:VCARD`,
updates: { firstName: 'Jane', lastName: 'Smith' },
},
expected: `BEGIN:VCARD
VERSION:4.0
UID:test-123
N:Smith;Jane;
FN:Jane Smith
END:VCARD`,
},
{
input: {
vCard: `BEGIN:VCARD
VERSION:4.0
UID:test-456
N:Doe;John;
FN:John Doe
EMAIL:old@example.com
TEL:+1234567890
END:VCARD`,
updates: { email: 'new@example.com', phone: '+9876543210' },
},
expected: `BEGIN:VCARD
VERSION:4.0
UID:test-456
N:Doe;John;
FN:John Doe
EMAIL:new@example.com
TEL:+9876543210
END:VCARD`,
},
{
input: {
vCard: `BEGIN:VCARD
VERSION:4.0
UID:test-789
N:Doe;John;
FN:John Doe
END:VCARD`,
updates: { email: 'added@example.com', phone: '+1111111111', notes: 'Test notes' },
},
expected: `BEGIN:VCARD
VERSION:4.0
UID:test-789
N:Doe;John;
FN:John Doe
EMAIL;TYPE=HOME:added@example.com
TEL;TYPE=HOME:+1111111111
NOTE:Test notes
END:VCARD`,
},
{
input: {
vCard: `BEGIN:VCARD
VERSION:4.0
UID:test-special
N:Doe;John;
FN:John Doe
NOTE:Old notes
END:VCARD`,
updates: { notes: 'New notes\nwith newlines, and commas' },
},
expected: `BEGIN:VCARD
VERSION:4.0
UID:test-special
N:Doe;John;
FN:John Doe
NOTE:New notes\\nwith newlines\\, and commas
END:VCARD`,
},
{
input: {
vCard: `BEGIN:VCARD
VERSION:4.0
UID:test-carriage
N:Doe;John;
FN:John Doe
END:VCARD`,
updates: { notes: 'Notes with\r\ncarriage returns' },
},
expected: `BEGIN:VCARD
VERSION:4.0
UID:test-carriage
N:Doe;John;
FN:John Doe
NOTE:Notes with\\ncarriage returns
END:VCARD`,
},
];
for (const test of tests) {
const output = updateVCard(test.input.vCard, test.input.updates);
assertEquals(output, test.expected);
}
});
Deno.test('that parseVCard works', () => {
const tests: {
input: string;
expected: Array<{
uid?: string;
firstName?: string;
lastName?: string;
middleNames?: string[];
title?: string;
email?: string;
phone?: string;
notes?: string;
}>;
}[] = [
{
input: `BEGIN:VCARD
VERSION:4.0
UID:test-123
N:Doe;John;Middle;Jr
FN:John Middle Doe Jr
EMAIL;TYPE=HOME:john@example.com
TEL;TYPE=HOME:+1234567890
NOTE:Test contact notes
END:VCARD`,
expected: [{
uid: 'test-123',
firstName: 'John',
lastName: 'Doe',
middleNames: ['Middle'],
title: 'Jr',
email: 'john@example.com',
phone: '+1234567890',
notes: 'Test contact notes',
}],
},
{
input: `BEGIN:VCARD
VERSION:3.0
UID:test-456
N:Smith;Jane;;
FN:Jane Smith
EMAIL:jane@example.com
TEL:+9876543210
END:VCARD`,
expected: [{
uid: 'test-456',
firstName: 'Jane',
lastName: 'Smith',
middleNames: [],
title: '',
email: 'jane@example.com',
phone: '+9876543210',
}],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:multi-1
N:Doe;John;
FN:John Doe
END:VCARD
BEGIN:VCARD
VERSION:4.0
UID:multi-2
N:Smith;Jane;
FN:Jane Smith
EMAIL;PREF=1:jane@example.com
END:VCARD`,
expected: [
{
uid: 'multi-1',
firstName: 'John',
lastName: 'Doe',
middleNames: [],
title: '',
},
{
uid: 'multi-2',
firstName: 'Jane',
lastName: 'Smith',
middleNames: [],
title: '',
email: 'jane@example.com',
},
],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:escaped-contact
N:Test;Contact;
FN:Contact Test
NOTE:Notes with\\nescaped newlines\\, and commas
END:VCARD`,
expected: [{
uid: 'escaped-contact',
firstName: 'Contact',
lastName: 'Test',
middleNames: [],
title: '',
notes: 'Notes with\nescaped newlines, and commas',
}],
},
{
input: `BEGIN:VCARD
VERSION:2.1
UID:version-21
N:Old;Format;
FN:Format Old
EMAIL:old@example.com
TEL:+1111111111
END:VCARD`,
expected: [{
uid: 'version-21',
firstName: 'Format',
lastName: 'Old',
middleNames: [],
title: '',
email: 'old@example.com',
phone: '+1111111111',
}],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:email-variations
N:Test;Email;
FN:Email Test
EMAIL:direct@example.com
EMAIL;TYPE=WORK:work@example.com
TEL:+1234567890
TEL;TYPE=WORK:+9876543210
END:VCARD`,
expected: [{
uid: 'email-variations',
firstName: 'Email',
lastName: 'Test',
middleNames: [],
title: '',
email: 'direct@example.com', // Only first email is captured
phone: '+1234567890', // Only first phone is captured
}],
},
{
input: `BEGIN:VCARD
VERSION:5.0
UID:invalid-version
N:Invalid;Version;
FN:Version Invalid
END:VCARD`,
expected: [{
uid: 'invalid-version',
firstName: 'Version',
lastName: 'Invalid',
middleNames: [],
title: '',
}],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:no-first-name
N:LastOnly;;
FN:LastOnly
END:VCARD`,
expected: [{ uid: 'no-first-name' }],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:empty-uid
N:Test;Empty;
FN:Empty Test
END:VCARD`,
expected: [{
firstName: 'Empty',
lastName: 'Test',
middleNames: [],
title: '',
uid: 'empty-uid',
}],
},
];
for (const test of tests) {
const output = parseVCard(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that parseVCard handles edge cases', () => {
const edgeCases: { input: string; description: string; expected: any[] }[] = [
{
input: '',
description: 'empty string',
expected: [],
},
{
input: 'Not a vCard at all',
description: 'invalid format',
expected: [],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:incomplete`,
description: 'incomplete vCard without END',
expected: [],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:missing-required
FN:Missing Required Fields
END:VCARD`,
description: 'vCard without N field',
expected: [{ uid: 'missing-required' }],
},
{
input: `BEGIN:VCARD
VERSION:4.0
UID:empty-fields
N:;;;
FN:Empty Fields
EMAIL:
TEL:
NOTE:
END:VCARD`,
description: 'vCard with empty field values',
expected: [{ uid: 'empty-fields', notes: '' }],
},
];
for (const test of edgeCases) {
const output = parseVCard(test.input);
assertEquals(output, test.expected, `Failed for: ${test.description}`);
}
});

View file

@ -1,36 +0,0 @@
import { assertEquals } from 'std/assert/assert_equals.ts';
import { humanFileSize } from './files.ts';
Deno.test('that humanFileSize works', () => {
const tests: { input: number; expected: string }[] = [
{
input: 1000,
expected: '1000 B',
},
{
input: 1024,
expected: '1.00 KB',
},
{
input: 10000,
expected: '9.77 KB',
},
{
input: 1,
expected: '1 B',
},
{
input: 1048576,
expected: '1.00 MB',
},
{
input: 1073741824,
expected: '1.00 GB',
},
];
for (const test of tests) {
const output = humanFileSize(test.input);
assertEquals(output, test.expected);
}
});

91
lib/utils/layout.tsx Normal file
View file

@ -0,0 +1,91 @@
import { renderToString } from 'preact-render-to-string';
import denoConfig from '/deno.json' with { type: 'json' };
import { RequestHandlerParams } from '/lib/page.ts';
import { AppConfig } from '/lib/config.ts';
import { escapeHtml, html } from '/public/ts/utils/misc.ts';
import Header from '/components/Header.tsx';
interface BasicLayoutOptions
extends Pick<RequestHandlerParams, 'user' | 'session' | 'request' | 'match' | 'isRunningLocally'> {
currentPath: string;
titlePrefix: string;
description?: string;
}
async function basicLayout(
htmlContent: string,
{ currentPath, titlePrefix, description, user }: BasicLayoutOptions,
) {
const config = await AppConfig.getConfig();
const defaultTitle = config.visuals.title || 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
const defaultDescription = config.visuals.description || `Have your files under your own control.`;
const enabledApps = config.core.enabledApps;
let title = defaultTitle;
if (titlePrefix) {
title = `${titlePrefix} - bewCloud`;
}
const headerReactNode = <Header route={currentPath} user={user} enabledApps={enabledApps} />;
const headerHtml = renderToString(headerReactNode);
return html`
<!DOCTYPE html>
<html lang="en" dir="ltr" class="h-full bg-slate-800">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description || defaultDescription)}">
<meta name="author" content="Bruno Bernardino">
<meta property="og:title" content="${escapeHtml(defaultTitle)}" />
<link rel="icon" href="/public/images/favicon-dark.png" type="image/png" />
<link rel="apple-touch-icon" href="/public/images/favicon-dark.png" />
<link rel="manifest" href="/public/manifest.json" />
<link rel="stylesheet" href="/public/scss/style.scss" />
<link rel="stylesheet" href="/public/css/tailwind.css" />
</head>
<script type="importmap">
${JSON.stringify(importMap)}
</script>
<body class="h-full">
${headerHtml} ${htmlContent}
</body>
</html>
`;
}
const importMap = denoConfig.frontendImports.reduce(
(importsObject: { imports: Record<string, string> }, importName: string) => {
const url = new URL(denoConfig.imports[importName as keyof typeof denoConfig.imports]).toString();
let fileName = url.replace('https://esm.sh/', '').split('?')[0].trim();
if (!fileName.endsWith('.mjs')) {
fileName = `${fileName}.mjs`;
}
importsObject.imports[importName] = `/public/js/${fileName}`;
return importsObject;
},
{ imports: {} },
);
export async function basicLayoutResponse(htmlContent: string, options: BasicLayoutOptions) {
const headers: HeadersInit = {
'content-type': 'text/html; charset=utf-8',
'content-security-policy':
`default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'`,
'x-frame-options': 'DENY',
'x-content-type-options': 'nosniff',
'strict-transport-security': 'max-age=31536000; includeSubDomains; preload',
};
return new Response(await basicLayout(htmlContent, options), {
headers,
});
}

View file

@ -1,270 +1,64 @@
import { currencyMap } from '/lib/types.ts'; import { serveFile } from '@std/http/file-server';
import { SupportedCurrencySymbol } from '/lib/types.ts'; import { transpile } from 'deno/emit';
import sass from 'sass';
export function isRunningLocally(request: Request): boolean { async function transpileTs(content: string, specifier: URL) {
try { const urlStr = specifier.toString();
const url = new URL(request.url); const result = await transpile(specifier, {
const hostname = url.hostname; load(specifier: string) {
if (specifier !== urlStr) {
// Local hostnames check return Promise.resolve({ kind: 'module', specifier, content: '' });
if (['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return true;
} }
return Promise.resolve({ kind: 'module', specifier, content });
// Private IP ranges check
const ipParts = hostname.split('.').map(Number);
// Check if the IP address is valid
if (ipParts.length !== 4 || ipParts.some((part) => isNaN(part) || part < 0 || part > 255)) {
return false;
}
// 10.0.0.0 - 10.255.255.255
if (ipParts[0] === 10) {
return true;
}
// 172.16.0.0 - 172.31.255.255
if (ipParts[0] === 172 && ipParts[1] >= 16 && ipParts[1] <= 31) {
return true;
}
// 192.168.0.0 - 192.168.255.255
if (ipParts[0] === 192 && ipParts[1] === 168) {
return true;
}
return false;
} catch (error) {
console.info('Failed to check if the request is running locally', error);
return false;
}
}
export function escapeHtml(unsafe: string) {
return unsafe.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
export function escapeXml(unsafe: string) {
return escapeHtml(unsafe).replaceAll('\r', '&#13;');
}
export function generateRandomCode(length = 6) {
const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9
const codeDigits = Array.from({ length }).map(getRandomDigit);
return codeDigits.join('');
}
/** This generates a random string, including unicode characters. */
export function generateRandomString(length = 10) {
const array = new Uint8Array(length * 2);
crypto.getRandomValues(array);
return Array.from(array)
.map((byte) => String.fromCharCode(byte))
.join('')
.slice(0, length);
}
export async function generateHash(value: string, algorithm: AlgorithmIdentifier) {
const hashedValueData = await crypto.subtle.digest(
algorithm,
new TextEncoder().encode(value),
);
const hashedValue = Array.from(new Uint8Array(hashedValueData)).map(
(byte) => byte.toString(16).padStart(2, '0'),
).join('');
return hashedValue;
}
export function splitArrayInChunks<T = any>(array: T[], chunkLength: number) {
const chunks = [];
let chunkIndex = 0;
const arrayLength = array.length;
while (chunkIndex < arrayLength) {
chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength));
}
return chunks;
}
export function validateEmail(email: string) {
const trimmedEmail = (email || '').trim().toLocaleLowerCase();
if (!trimmedEmail) {
return false;
}
const requiredCharsNotInEdges = ['@', '.'];
return requiredCharsNotInEdges.every((char) =>
trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char)
);
}
export function validateUrl(url: string) {
const trimmedUrl = (url || '').trim().toLocaleLowerCase();
if (!trimmedUrl) {
return false;
}
if (!trimmedUrl.includes('://')) {
return false;
}
const protocolIndex = trimmedUrl.indexOf('://');
const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3);
if (!urlAfterProtocol) {
return false;
}
return true;
}
// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a
export async function concurrentPromises<T>(
generators: (() => Promise<T>)[],
maxConcurrency: number,
): Promise<T[]> {
const iterator = generators.entries();
const results: T[] = [];
let hasFailed = false;
await Promise.all(
Array.from(Array(maxConcurrency), async () => {
for (const [index, promiseToExecute] of iterator) {
if (hasFailed) {
break;
}
try {
results[index] = await promiseToExecute();
} catch (error) {
hasFailed = true;
throw error;
}
}
}),
);
return results;
}
const MAX_RESPONSE_TIME_IN_MS = 10_000;
export async function fetchUrl(url: string) {
const abortController = new AbortController();
const requestCancelTimeout = setTimeout(() => {
abortController.abort();
}, MAX_RESPONSE_TIME_IN_MS);
const response = await fetch(url, {
signal: abortController.signal,
});
if (requestCancelTimeout) {
clearTimeout(requestCancelTimeout);
}
const urlContents = await response.text();
return urlContents;
}
export async function fetchUrlAsGooglebot(url: string) {
const abortController = new AbortController();
const requestCancelTimeout = setTimeout(() => {
abortController.abort();
}, MAX_RESPONSE_TIME_IN_MS);
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
}, },
signal: abortController.signal,
}); });
return result.get(urlStr) || '';
if (requestCancelTimeout) {
clearTimeout(requestCancelTimeout);
} }
const urlContents = await response.text(); export async function serveFileWithTs(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) {
return urlContents; const response = await serveFile(request, filePath);
if (response.status !== 200) {
return response;
} }
export async function fetchUrlWithProxy(url: string) { const tsCode = await response.text();
const abortController = new AbortController(); const jsCode = await transpileTs(tsCode, new URL('file:///src.ts'));
const requestCancelTimeout = setTimeout(() => { const { headers } = response;
abortController.abort(); headers.set('content-type', 'application/javascript; charset=utf-8');
}, MAX_RESPONSE_TIME_IN_MS); headers.delete('content-length');
const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, { return new Response(jsCode, {
signal: abortController.signal, status: response.status,
statusText: response.statusText,
headers,
...(extraHeaders || {}),
}); });
if (requestCancelTimeout) {
clearTimeout(requestCancelTimeout);
} }
const urlContents = await response.text(); function transpileSass(content: string) {
return urlContents; const compiler = sass(content);
return compiler.to_string('compressed') as string;
} }
export async function fetchUrlWithRetries(url: string) { export async function serveFileWithSass(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) {
try { const response = await serveFile(request, filePath);
const text = await fetchUrl(url);
return text; if (response.status !== 200) {
} catch (_error) { return response;
try {
const text = await fetchUrlAsGooglebot(url);
return text;
} catch (_error) {
const text = await fetchUrlWithProxy(url);
return text;
}
}
} }
export function convertFormDataToObject(formData: FormData): Record<string, any> { const sassCode = await response.text();
return JSON.parse(JSON.stringify(Object.fromEntries(formData))); const cssCode = transpileSass(sassCode);
} const { headers } = response;
headers.set('content-type', 'text/css; charset=utf-8');
headers.delete('content-length');
export function convertObjectToFormData(formDataObject: Record<string, any>): FormData { return new Response(cssCode, {
const formData = new FormData(); status: response.status,
statusText: response.statusText,
for (const key of Object.keys(formDataObject || {})) { headers,
if (Array.isArray(formDataObject[key])) { ...(extraHeaders || {}),
formData.append(key, formDataObject[key].join(',')); });
} else {
formData.append(key, formDataObject[key]);
}
}
return formData;
}
export const capitalizeWord = (string: string) => {
return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`;
};
export function getRandomItem<T>(items: Readonly<Array<T>>): T {
return items[Math.floor(Math.random() * items.length)];
}
export function formatNumber(currency: SupportedCurrencySymbol, number: number) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyMap.get(currency) || 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(Number.parseFloat(`${number}`.replace(',', '.')));
}
export function formatInputToNumber(numberInput: number | string): number {
return Number.parseFloat(`${numberInput}`.replace(',', '.'));
} }

View file

@ -1,4 +1,4 @@
import { join } from 'std/path/join.ts'; import { join } from '@std/path';
import { lookup } from 'mrmime'; import { lookup } from 'mrmime';
export function getProperDestinationPath(url: string) { export function getProperDestinationPath(url: string) {

225
lib/utils/webdav_test.ts Normal file
View file

@ -0,0 +1,225 @@
import { assertEquals } from '@std/assert';
import { parse, stringify } from '@libs/xml';
import { addDavPrefixToKeys, getProperDestinationPath, getPropertyNames } from './webdav.ts';
Deno.test('that getProperDestinationPath works', () => {
const tests: { input: string; expected?: string }[] = [
{
input: `http://127.0.0.1/dav/12345-abcde-67890`,
expected: 'dav/12345-abcde-67890',
},
{
input: `http://127.0.0.1/dav/spaced-%20uid`,
expected: 'dav/spaced- uid',
},
{
input: `http://127.0.0.1/dav/something-deeper/spaced-%C3%A7uid`,
expected: 'dav/something-deeper/spaced-çuid',
},
];
for (const test of tests) {
const output = getProperDestinationPath(test.input);
if (test.expected) {
assertEquals(output, test.expected);
}
}
});
Deno.test('that addDavPrefixToKeys works', () => {
const tests: {
input: { object: Record<string, any> | Record<string, any>[]; prefix?: string };
expected: Record<string, any>;
}[] = [
{
input: {
object: {
displayname: 'test',
},
},
expected: {
'D:displayname': 'test',
},
},
{
input: {
object: [
{ displayname: 'test' },
{ color: 'black' },
],
},
expected: [
{ 'D:displayname': 'test' },
{ 'D:color': 'black' },
],
},
{
input: {
object: { '@version': '1.0', '@encoding': 'UTF-8', displayname: 'test', color: 'black' },
},
expected: {
'@version': '1.0',
'@encoding': 'UTF-8',
'D:displayname': 'test',
'D:color': 'black',
},
},
{
input: {
object: { displayname: 'test', color: 'black' },
prefix: 'S:',
},
expected: {
'S:displayname': 'test',
'S:color': 'black',
},
},
];
for (const test of tests) {
const output = addDavPrefixToKeys(test.input.object, test.input.prefix);
if (test.expected) {
assertEquals(output, test.expected);
}
}
});
Deno.test('that getPropertyNames works', () => {
const tests: {
input: Record<string, any>;
expected: string[];
}[] = [
{
input: {
'D:propfind': {
'D:prop': {
'D:displayname': 'test',
},
},
},
expected: ['displayname'],
},
{
input: {
'D:propfind': {
'D:prop': {
'D:displayname': 'test',
'D:color': 'black',
},
},
},
expected: ['displayname', 'color'],
},
{
input: {},
expected: ['allprop'],
},
];
for (const test of tests) {
const output = getPropertyNames(test.input);
if (test.expected) {
assertEquals(output, test.expected);
}
}
});
Deno.test('that @libs/xml.parse works', () => {
const tests: { input: string; expected: Record<string, any> }[] = [
{
input: `<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname>test</displayname>
</prop>
</propfind>`,
expected: {
xml: {
'@version': 1,
'@encoding': 'UTF-8',
},
propfind: {
'@xmlns': 'DAV:',
prop: {
displayname: 'test',
},
},
},
},
{
input: `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname>test</D:displayname>
</D:prop>
</D:propfind>`,
expected: {
xml: {
'@version': 1,
'@encoding': 'UTF-8',
},
'D:propfind': {
'@xmlns:D': 'DAV:',
'D:prop': {
'D:displayname': 'test',
},
},
},
},
];
for (const test of tests) {
const output = parse(test.input);
assertEquals(output, test.expected);
}
});
Deno.test('that @libs/xml.stringify works', () => {
const tests: { input: Record<string, any>; expected: string }[] = [
{
input: {
xml: {
'@version': '1.0',
'@encoding': 'UTF-8',
},
'D:propfind': {
'D:prop': {
'D:displayname': 'test',
},
},
},
expected: `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind>
<D:prop>
<D:displayname>test</D:displayname>
</D:prop>
</D:propfind>`,
},
{
input: {
xml: {
'@version': '1.0',
'@encoding': 'UTF-8',
},
'D:propfind': {
'@xmlns:D': 'DAV:',
'D:prop': {
'D:displayname': 'test',
},
},
},
expected: `<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname>test</D:displayname>
</D:prop>
</D:propfind>`,
},
];
for (const test of tests) {
const output = stringify(test.input);
assertEquals(output, test.expected);
}
});

151
main.ts
View file

@ -1,13 +1,144 @@
/// <reference no-default-lib="true" /> import routes, { Route } from './routes.ts';
/// <reference lib="dom" /> import { startCrons } from './crons/index.ts';
/// <reference lib="dom.iterable" /> import { Page } from './lib/page.ts';
/// <reference lib="dom.asynciterable" /> import { AppConfig } from './lib/config.ts';
/// <reference lib="deno.ns" />
import 'std/dotenv/load.ts'; const config = await AppConfig.getConfig();
const MAX_REQUEST_SIZE_IN_MEGABYTES = config.core.maxRequestSizeInMegabytes;
const MAX_UPLOAD_SIZE_IN_MEGABYTES = config.files.maxUploadSizeInMegabytes;
const MAX_REQUEST_SIZE_IN_BYTES = MAX_REQUEST_SIZE_IN_MEGABYTES * 1024 * 1024;
const MAX_UPLOAD_SIZE_IN_BYTES = MAX_UPLOAD_SIZE_IN_MEGABYTES * 1024 * 1024;
import { start } from 'fresh/server.ts'; function applyCorsHeadersToResponse(origin: string, response: Response) {
import manifest from './fresh.gen.ts'; const headers = response.headers;
import config from './fresh.config.ts'; headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
headers.set(
'Access-Control-Allow-Headers',
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
);
headers.set(
'Access-Control-Allow-Methods',
'POST, OPTIONS, GET, PUT, DELETE',
);
if (!headers.get('content-security-policy')) {
headers.set(
'Content-Security-Policy',
"default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
);
}
if (!headers.get('x-frame-options')) {
headers.set('X-Frame-Options', 'DENY');
}
if (!headers.get('x-content-type-options')) {
headers.set('X-Content-Type-Options', 'nosniff');
}
if (!headers.get('strict-transport-security')) {
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
}
}
await start(manifest, config); function handleLogging(request: Request, response: Response) {
console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`);
// 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);
// try {
// console.info((await request.clone().text()) || '<No Body>');
// } catch (_error) {
// console.info('<No Body>');
// }
// console.info(`Response`, response.headers);
// console.info(`Status`, response.status);
// }
return response;
}
async function handler(request: Request) {
const contentLength = request.headers.get('content-length');
const path = new URL(request.url).pathname;
const isUploadRequest = path.startsWith('/api/files/upload') || path.startsWith('/dav');
const maxSizeInBytes = isUploadRequest ? MAX_UPLOAD_SIZE_IN_BYTES : MAX_REQUEST_SIZE_IN_BYTES;
if (contentLength && parseInt(contentLength, 10) > maxSizeInBytes) {
return new Response('Payload too large', { status: 413 });
}
const origin = request.headers.get('Origin') || '*';
// CORS headers for non-DAV routes
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,
});
const headers = response.headers;
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS, GET, PUT, DELETE');
headers.set(
'Access-Control-Allow-Headers',
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
);
return response;
}
const routeKeys = Object.keys(routes);
for (const routeKey of routeKeys) {
const route: Route = routes[routeKey];
const match = route.pattern.exec(request.url);
if (match) {
const response = await route.handler(request, match);
applyCorsHeadersToResponse(origin, response);
handleLogging(request, response);
return response;
}
}
const notFoundPage: Page = (await import(`/pages/404.ts`)).default;
const response = await notFoundPage.get!({
request,
match: new URLPattern({ pathname: '/' }).exec(request.url) as URLPatternResult,
isRunningLocally: false,
});
handleLogging(request, response);
return new Response(response.body, {
status: 404,
headers: response.headers,
});
}
async function notifyServiceManagerReady() {
const socketAddress = Deno.env.get('NOTIFY_SOCKET');
if (typeof socketAddress !== 'string') {
return;
}
const result = await (new Deno.Command('systemd-notify', {
args: ['--ready', `MESSAGE=bewCloud is ready`],
})).output();
if (!result.success) {
const output = new TextDecoder().decode(result.stderr);
throw new Deno.errors.NotCapable(`Failed to execute “systemd-notify”: ${output} (code ${result.code})`);
}
}
export const abortController = new AbortController();
const PORT = Deno.env.get('PORT') || 8000;
Deno.serve({ port: PORT as number, signal: abortController.signal }, handler);
startCrons();
notifyServiceManagerReady();

View file

@ -1,47 +1,44 @@
import { assert } from 'std/assert/assert.ts'; import { assertEquals } from '@std/assert';
import { assertEquals } from 'std/assert/assert_equals.ts';
import { createHandler, ServeHandlerInfo } from 'fresh/server.ts';
import manifest from './fresh.gen.ts'; import { abortController } from './main.ts';
import config from './fresh.config.ts';
// @ts-ignore Deno's newer types seem to have messed this up const baseUrl = 'http://localhost:8000';
const CONN_INFO: ServeHandlerInfo = {
remoteAddr: { hostname: '127.0.0.1', port: 53496, transport: 'tcp' },
};
Deno.test('Basic routes', async (testContext) => { Deno.test({
const handler = await createHandler(manifest, config); name: 'HTTP Server',
fn: async () => {
await testContext.step('#1 GET /', async () => { let response = await fetch(`${baseUrl}/login`);
const response = await handler(new Request('http://127.0.0.1/'), CONN_INFO);
assertEquals(response.status, 303);
});
await testContext.step('#2 GET /login', async () => {
const response = await handler(new Request('http://127.0.0.1/login'), CONN_INFO);
const text = await response.text();
assert(text.includes('bewCloud'));
assertEquals(response.status, 200); assertEquals(response.status, 200);
});
await testContext.step('#3 GET /blah', async () => { let responseText = await response.text();
const response = await handler(new Request('http://127.0.0.1/blah'), CONN_INFO); assertEquals(responseText.includes('bewCloud'), true);
const text = await response.text();
assert(text.includes('404 - Page not found')); response = await fetch(`${baseUrl}/`, {
redirect: 'manual',
});
assertEquals(response.status, 303);
assertEquals(response.headers.get('Location'), `/login`);
response = await fetch(`${baseUrl}/blah`);
assertEquals(response.status, 404); assertEquals(response.status, 404);
});
await testContext.step('#4 POST /login', async () => { responseText = await response.text();
assertEquals(responseText.includes('404 - Page not found'), true);
const formData = new FormData(); const formData = new FormData();
formData.append('email', 'user@example.com'); formData.append('email', 'user@example.com');
const request = new Request('http://127.0.0.1/login', {
response = await fetch(`${baseUrl}/login`, {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
const response = await handler(request, CONN_INFO);
const text = await response.text();
assert(text.includes('Error: Password is too short'));
assertEquals(response.status, 200); assertEquals(response.status, 200);
});
responseText = await response.text();
assertEquals(responseText.includes('Error: Password is too short'), true);
abortController.abort('Test finished');
},
sanitizeResources: false,
sanitizeOps: false,
}); });

View file

@ -1,4 +1,4 @@
import 'std/dotenv/load.ts'; import '@std/dotenv/load';
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';

View file

@ -0,0 +1,10 @@
import page from '/lib/page.ts';
function get() {
return new Response('Redirecting...', { status: 301, headers: { 'Location': '/caldav' } });
}
export default page({
get,
accessMode: 'public',
});

Some files were not shown because too many files have changed in this diff Show more