From e337859a22a66266bd78455d55a23ce173e090df Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sun, 25 May 2025 15:48:53 +0100 Subject: [PATCH] Implement a more robust Config (#60) * Implement a more robust Config This moves the configuration variables from the `.env` file to a new `bewcloud.config.ts` file. Note that DB connection and secrets are still in the `.env` file. This will allow for more reliable and easier personalized configurations, and was a requirement to start working on adding SSO (#13). For now, `.env`-based config will still be allowed and respected (overriden by `bewcloud.config.ts`), but in the future I'll probably remove it (some major upgrade). * Update deploy script to also copy the new config file --- .dockerignore | 1 + .env.sample | 13 -- .github/workflows/deploy.yml | 2 +- .gitignore | 3 + README.md | 15 ++- bewcloud.config.sample.ts | 26 ++++ components/Header.tsx | 14 +- components/files/MainFiles.tsx | 4 +- crons/index.ts | 8 +- docker-compose.yml | 2 +- fresh.config.ts | 2 +- islands/Settings.tsx | 11 +- islands/files/FilesWrapper.tsx | 4 +- lib/auth.ts | 27 ++-- lib/config.ts | 236 ++++++++++++++++++++++++++------- lib/models/files.ts | 54 ++++---- lib/models/user.ts | 8 +- lib/providers/brevo.ts | 7 +- lib/types.ts | 37 ++++++ lib/utils/misc.ts | 22 --- routes/_app.tsx | 13 +- routes/dav.tsx | 20 +-- routes/expenses.tsx | 4 +- routes/files.tsx | 7 +- routes/login.tsx | 39 ++++-- routes/news.tsx | 4 +- routes/notes.tsx | 4 +- routes/photos.tsx | 4 +- routes/settings.tsx | 29 +++- routes/signup.tsx | 21 +-- 30 files changed, 443 insertions(+), 198 deletions(-) create mode 100644 bewcloud.config.sample.ts diff --git a/.dockerignore b/.dockerignore index 6722551..1454c63 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,4 @@ README.md node_modules _fresh data-files +bewcloud.config.sample.ts diff --git a/.env.sample b/.env.sample index de9f220..25c7835 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,4 @@ PORT=8000 -BASE_URL="http://localhost:8000" POSTGRESQL_HOST="postgresql" # docker container name or external hostname/IP POSTGRESQL_USER="postgres" @@ -12,15 +11,3 @@ JWT_SECRET="fake" PASSWORD_SALT="fake" BREVO_API_KEY="fake" - -CONFIG_ALLOW_SIGNUPS="false" -CONFIG_ENABLED_APPS="news,notes,photos,expenses" # dashboard and files cannot be disabled -CONFIG_FILES_ROOT_PATH="data-files" -CONFIG_ENABLE_EMAILS="false" # if true, email verification will be required for signups (using Brevo) -CONFIG_ENABLE_FOREVER_SIGNUP="true" # if true, all signups become active for 100 years -# CONFIG_ALLOWED_COOKIE_DOMAINS="example.com,example.net" # can be set to allow more than the BASE_URL's domain for session cookies -# CONFIG_SKIP_COOKIE_DOMAIN_SECURITY="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. - -# CUSTOM_TITLE="" -# CUSTOM_DESCRIPTION="" -HELP_EMAIL="help@bewcloud.com" # if empty, "need help" sections will be disabled \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e1aa356..60de93d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,4 +30,4 @@ jobs: SSH_KEY: ${{ secrets.SSH_KEY }} - name: Deploy via SSH - run: ssh server 'cd apps/bewcloud && git add . && git stash && git pull origin main && git stash clear && git remote prune origin && cp ../../scripts/config/bewcloud/.env . && cp ../../scripts/config/bewcloud/docker-compose.yml . && docker system prune -f && docker compose up -d --build && docker compose ps && docker compose logs' + run: ssh server 'cd apps/bewcloud && git add . && git stash && git pull origin main && git stash clear && git remote prune origin && cp ../../scripts/config/bewcloud/.env . && cp ../../scripts/config/bewcloud/bewcloud.config.ts . && cp ../../scripts/config/bewcloud/docker-compose.yml . && docker system prune -f && docker compose up -d --build && docker compose ps && docker compose logs' diff --git a/.gitignore b/.gitignore index 5482ebe..f96df91 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ db/ # Files data-files/ + +# Config +bewcloud.config.ts diff --git a/README.md b/README.md index 9c8d025..cc174a5 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,31 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github Or on your own machine: -Download/copy [`docker-compose.yml`](/docker-compose.yml) and [`.env.sample`](/.env.sample) as `.env`. +Download/copy [`docker-compose.yml`](/docker-compose.yml), [`.env.sample`](/.env.sample) as `.env`, and [`bewcloud.config.sample.ts`](/bewcloud.config.sample.ts) as `bewcloud.config.ts`. ```sh $ mkdir data-files # local directory for storing user-uploaded files -$ sudo chown -R 1993:1993 data-files # solves permission related issues in the container with uploading files +$ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files $ docker compose up -d # makes the app available at http://localhost:8000 -$ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any updates) +$ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates) ``` Alternatively, check the [Development section below](#development). > [!IMPORTANT] -> Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="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. > [!NOTE] -> `1993:1993` 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. +> `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. ## Development ### Requirements -- Don't forget to set up your `.env` file based on `.env.sample`. +> [!IMPORTANT] +> Don't forget to set up your `.env` file based on `.env.sample`. +> Don't forget to set up your `bewcloud.config.ts` file based on `bewcloud.config.sample.ts`. + - This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work. - For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed. diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts new file mode 100644 index 0000000..de929f3 --- /dev/null +++ b/bewcloud.config.sample.ts @@ -0,0 +1,26 @@ +import { Config, PartialDeep } from './lib/types.ts'; + +/** Check the Config type for all the possible options and instructions. */ +const config: PartialDeep = { + 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" + 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 Brevo) + enableForeverSignup: true, // If true, all signups become active for 100 years + // 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 + }, + // files: { + // rootPath: 'data-files', + // }, + // core: { + // enabledApps: ['news', 'notes', 'photos', 'expenses'], // dashboard and files cannot be disabled + // }, + // visuals: { + // title: 'My own cloud', + // description: 'This is my own cloud!', + // helpEmail: '', + // }, +}; + +export default config; diff --git a/components/Header.tsx b/components/Header.tsx index ad01016..19e701b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,11 +1,11 @@ import { Head } from 'fresh/runtime.ts'; -import { User } from '/lib/types.ts'; -import { isAppEnabled } from '/lib/config.ts'; +import { OptionalApp, User } from '/lib/types.ts'; interface Data { route: string; user?: User; + enabledApps: OptionalApp[]; } interface MenuItem { @@ -13,7 +13,7 @@ interface MenuItem { label: string; } -export default function Header({ route, user }: Data) { +export default function Header({ route, user, enabledApps }: Data) { const activeClass = 'bg-slate-800 text-white rounded-md px-3 py-2 text-sm font-medium'; const defaultClass = 'text-slate-300 hover:bg-slate-700 hover:text-white rounded-md px-3 py-2 text-sm font-medium'; @@ -28,7 +28,7 @@ export default function Header({ route, user }: Data) { url: '/dashboard', label: 'Dashboard', }, - isAppEnabled('news') + enabledApps.includes('news') ? { url: '/news', label: 'News', @@ -38,19 +38,19 @@ export default function Header({ route, user }: Data) { url: '/files', label: 'Files', }, - isAppEnabled('notes') + enabledApps.includes('notes') ? { url: '/notes', label: 'Notes', } : null, - isAppEnabled('photos') + enabledApps.includes('photos') ? { url: '/photos', label: 'Photos', } : null, - isAppEnabled('expenses') + enabledApps.includes('expenses') ? { url: '/expenses', label: 'Expenses', diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx index a6270aa..c7434bd 100644 --- a/components/files/MainFiles.tsx +++ b/components/files/MainFiles.tsx @@ -1,7 +1,6 @@ import { useSignal } from '@preact/signals'; import { Directory, DirectoryFile } from '/lib/types.ts'; -import { baseUrl } from '/lib/utils/misc.ts'; import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx'; import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx'; import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx'; @@ -33,9 +32,10 @@ interface MainFilesProps { initialDirectories: Directory[]; initialFiles: DirectoryFile[]; initialPath: string; + baseUrl: string; } -export default function MainFiles({ initialDirectories, initialFiles, initialPath }: MainFilesProps) { +export default function MainFiles({ initialDirectories, initialFiles, initialPath, baseUrl }: MainFilesProps) { const isAdding = useSignal(false); const isUploading = useSignal(false); const isDeleting = useSignal(false); diff --git a/crons/index.ts b/crons/index.ts index 9368994..5f0b162 100644 --- a/crons/index.ts +++ b/crons/index.ts @@ -1,10 +1,10 @@ import { Cron } from 'https://deno.land/x/croner@8.1.2/dist/croner.js'; -import { isAppEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { cleanupSessions } from './sessions.ts'; import { cleanupOldArticles, fetchNewArticles } from './news.ts'; -export function startCrons() { +export async function startCrons() { new Cron( // At 03:06 every day. '6 3 * * *', @@ -15,13 +15,13 @@ export function startCrons() { async () => { await cleanupSessions(); - if (isAppEnabled('news')) { + if (await AppConfig.isAppEnabled('news')) { await cleanupOldArticles(); } }, ); - if (isAppEnabled('news')) { + if (await AppConfig.isAppEnabled('news')) { new Cron( // Every 30 minutes. '*/30 * * * *', diff --git a/docker-compose.yml b/docker-compose.yml index c86bcf4..20c3426 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: website: - image: ghcr.io/bewcloud/bewcloud:main + image: ghcr.io/bewcloud/bewcloud:main # alternatively, you can use a specific version/tag, for greater stability restart: always ports: - 127.0.0.1:8000:8000 diff --git a/fresh.config.ts b/fresh.config.ts index d3300da..8f0c2b0 100644 --- a/fresh.config.ts +++ b/fresh.config.ts @@ -6,7 +6,7 @@ import { startCrons } from '/crons/index.ts'; const isBuildMode = Deno.args.includes('build'); if (!isBuildMode) { - startCrons(); + await startCrons(); } export default defineConfig({ diff --git a/islands/Settings.tsx b/islands/Settings.tsx index 64fea72..3826147 100644 --- a/islands/Settings.tsx +++ b/islands/Settings.tsx @@ -1,7 +1,6 @@ -import { convertObjectToFormData, helpEmail } from '/lib/utils/misc.ts'; +import { convertObjectToFormData } from '/lib/utils/misc.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts'; -import { isAppEnabled } from '/lib/config.ts'; interface SettingsProps { formData: Record; @@ -14,6 +13,8 @@ interface SettingsProps { message: string; }; currency?: SupportedCurrencySymbol; + isExpensesAppEnabled: boolean; + helpEmail: string; } export type Action = @@ -119,7 +120,9 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr return fields; } -export default function Settings({ formData: formDataObject, error, notice, currency }: SettingsProps) { +export default function Settings( + { formData: formDataObject, error, notice, currency, isExpensesAppEnabled, helpEmail }: SettingsProps, +) { const formData = convertObjectToFormData(formDataObject); const action = getFormDataField(formData, 'action') as Action; @@ -174,7 +177,7 @@ export default function Settings({ formData: formDataObject, error, notice, curr - {isAppEnabled('expenses') + {isExpensesAppEnabled ? ( <>

Change your currency

diff --git a/islands/files/FilesWrapper.tsx b/islands/files/FilesWrapper.tsx index 1b4b800..79670f8 100644 --- a/islands/files/FilesWrapper.tsx +++ b/islands/files/FilesWrapper.tsx @@ -5,17 +5,19 @@ interface FilesWrapperProps { initialDirectories: Directory[]; initialFiles: DirectoryFile[]; initialPath: string; + baseUrl: 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 }: FilesWrapperProps, + { initialDirectories, initialFiles, initialPath, baseUrl }: FilesWrapperProps, ) { return ( ); } diff --git a/lib/auth.ts b/lib/auth.ts index b6ff4bd..e8abc3e 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,10 +3,10 @@ import { decodeBase64 } from 'std/encoding/base64.ts'; import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import 'std/dotenv/load.ts'; -import { baseUrl, generateHash, isRunningLocally } from './utils/misc.ts'; +import { generateHash, isRunningLocally } from './utils/misc.ts'; import { User, UserSession } from './types.ts'; import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts'; -import { isCookieDomainAllowed, isCookieDomainSecurityDisabled } from './config.ts'; +import { AppConfig } from './config.ts'; const JWT_SECRET = Deno.env.get('JWT_SECRET') || ''; export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || ''; @@ -19,7 +19,7 @@ export interface JwtData { }; } -const isBaseUrlAnIp = () => /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(baseUrl); +const isUrlAnIp = (baseUrl: string) => /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(baseUrl); const textToData = (text: string) => new TextEncoder().encode(text); @@ -51,10 +51,13 @@ async function verifyAuthJwt(key: CryptoKey, jwt: string) { throw new Error('Invalid JWT'); } -function resolveCookieDomain(request: Request) { - if (!isBaseUrlAnIp() || isRunningLocally(request)) { +async function resolveCookieDomain(request: Request) { + const config = await AppConfig.getConfig(); + const baseUrl = config.auth.baseUrl; + + if (!isUrlAnIp(baseUrl) || isRunningLocally(request)) { const domain = new URL(request.url).hostname; - if (isCookieDomainAllowed(domain)) { + if (await AppConfig.isCookieDomainAllowed(domain)) { return domain; } return baseUrl.replace('https://', '').replace('http://', '').split(':')[0]; @@ -170,10 +173,10 @@ export async function logoutUser(request: Request) { secure: isRunningLocally(request) ? false : true, httpOnly: true, sameSite: 'Lax', - domain: resolveCookieDomain(request), + domain: await resolveCookieDomain(request), }; - if (isCookieDomainSecurityDisabled()) { + if (await AppConfig.isCookieDomainSecurityDisabled()) { delete cookie.domain; } @@ -223,10 +226,10 @@ export async function createSessionCookie( secure: isRunningLocally(request) ? false : true, httpOnly: true, sameSite: 'Lax', - domain: resolveCookieDomain(request), + domain: await resolveCookieDomain(request), }; - if (isCookieDomainSecurityDisabled()) { + if (await AppConfig.isCookieDomainSecurityDisabled()) { delete cookie.domain; } @@ -251,10 +254,10 @@ export async function updateSessionCookie( secure: isRunningLocally(request) ? false : true, httpOnly: true, sameSite: 'Lax', - domain: resolveCookieDomain(request), + domain: await resolveCookieDomain(request), }; - if (isCookieDomainSecurityDisabled()) { + if (await AppConfig.isCookieDomainSecurityDisabled()) { delete cookie.domain; } diff --git a/lib/config.ts b/lib/config.ts index 497aca1..f402971 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,57 +1,203 @@ -import 'std/dotenv/load.ts'; - import { UserModel } from './models/user.ts'; +import { Config, OptionalApp } from './types.ts'; -export async function isSignupAllowed() { - const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true'; +export class AppConfig { + private static config: Config; - const areThereAdmins = await UserModel.isThereAnAdmin(); - - if (areSignupsAllowed || !areThereAdmins) { - return true; + private static getDefaultConfig(): Config { + return { + auth: { + baseUrl: 'http://localhost:8000', + allowSignups: false, + enableEmailVerification: false, + enableForeverSignup: true, + allowedCookieDomains: [], + skipCookieDomainSecurity: false, + }, + files: { + rootPath: 'data-files', + }, + core: { + enabledApps: ['news', 'notes', 'photos', 'expenses'], + }, + visuals: { + title: '', + description: '', + helpEmail: 'help@bewcloud.com', + }, + }; } - return false; -} + /** This allows for backwards-compatibility with the old config format, which was in the .env file. */ + private static async getLegacyConfigFromEnv(): Promise { + const defaultConfig = this.getDefaultConfig(); -export function isAppEnabled(app: 'news' | 'notes' | 'photos' | 'expenses') { - const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') || '').split(',') as typeof app[]; + if (typeof Deno === 'undefined') { + return defaultConfig; + } - return enabledApps.includes(app); -} + await import('std/dotenv/load.ts'); -export function isCookieDomainAllowed(domain: string) { - const allowedDomains = (Deno.env.get('CONFIG_ALLOWED_COOKIE_DOMAINS') || '').split(',') as typeof domain[]; + const baseUrl = Deno.env.get('BASE_URL') ?? defaultConfig.auth.baseUrl; + const allowSignups = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true'; + const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') ?? '').split(',') as OptionalApp[]; + const filesRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH') ?? defaultConfig.files.rootPath; + const enableEmailVerification = (Deno.env.get('CONFIG_ENABLE_EMAILS') ?? 'false') === 'true'; + const enableForeverSignup = (Deno.env.get('CONFIG_ENABLE_FOREVER_SIGNUP') ?? 'true') === 'true'; + const allowedCookieDomains = (Deno.env.get('CONFIG_ALLOWED_COOKIE_DOMAINS') || '').split(',').filter( + Boolean, + ) as string[]; + const skipCookieDomainSecurity = Deno.env.get('CONFIG_SKIP_COOKIE_DOMAIN_SECURITY') === 'true'; + const title = Deno.env.get('CUSTOM_TITLE') ?? defaultConfig.visuals.title; + const description = Deno.env.get('CUSTOM_DESCRIPTION') ?? defaultConfig.visuals.description; + const helpEmail = Deno.env.get('HELP_EMAIL') ?? defaultConfig.visuals.helpEmail; - if (allowedDomains.length === 0) { - return true; + return { + ...defaultConfig, + auth: { + ...defaultConfig.auth, + baseUrl, + allowSignups, + enableEmailVerification, + enableForeverSignup, + allowedCookieDomains, + skipCookieDomainSecurity, + }, + files: { + ...defaultConfig.files, + rootPath: filesRootPath, + }, + core: { + ...defaultConfig.core, + enabledApps, + }, + visuals: { + ...defaultConfig.visuals, + title, + description, + helpEmail, + }, + }; } - return allowedDomains.includes(domain); -} - -export function isCookieDomainSecurityDisabled() { - const isCookieDomainSecurityDisabled = Deno.env.get('CONFIG_SKIP_COOKIE_DOMAIN_SECURITY') === 'true'; - - return isCookieDomainSecurityDisabled; -} - -export function isEmailEnabled() { - const areEmailsAllowed = Deno.env.get('CONFIG_ENABLE_EMAILS') === 'true'; - - return areEmailsAllowed; -} - -export function isForeverSignupEnabled() { - const areForeverAccountsEnabled = Deno.env.get('CONFIG_ENABLE_FOREVER_SIGNUP') === 'true'; - - return areForeverAccountsEnabled; -} - -export function getFilesRootPath() { - const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH') || ''; - - const filesRootPath = `${Deno.cwd()}/${configRootPath}`; - - return filesRootPath; + private static async loadConfig(): Promise { + if (this.config) { + return; + } + + let initialConfig = this.getDefaultConfig(); + + if ( + typeof Deno.env.get('BASE_URL') === 'string' || typeof Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'string' || + typeof Deno.env.get('CONFIG_ENABLED_APPS') === 'string' + ) { + console.warn( + '\nDEPRECATION WARNING: .env file has config variables. This will be used but is deprecated. Please use the bewcloud.config.ts file instead.', + ); + + initialConfig = await this.getLegacyConfigFromEnv(); + } + + const config: Config = { + ...initialConfig, + }; + + try { + const configFromFile: Config = (await import(`${Deno.cwd()}/bewcloud.config.ts`)).default; + + this.config = { + ...config, + auth: { + ...config.auth, + ...configFromFile.auth, + }, + files: { + ...config.files, + ...configFromFile.files, + }, + core: { + ...config.core, + ...configFromFile.core, + }, + visuals: { + ...config.visuals, + ...configFromFile.visuals, + }, + }; + + console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n'); + + return; + } catch (error) { + console.error('Error loading config from bewcloud.config.ts. Using default and legacy config instead.', error); + } + + this.config = config; + } + + static async getConfig(): Promise { + await this.loadConfig(); + + return this.config; + } + + static async isSignupAllowed(): Promise { + await this.loadConfig(); + + const areSignupsAllowed = this.config.auth.allowSignups; + + const areThereAdmins = await UserModel.isThereAnAdmin(); + + if (areSignupsAllowed || !areThereAdmins) { + return true; + } + + return false; + } + + static async isAppEnabled(app: OptionalApp): Promise { + await this.loadConfig(); + + const enabledApps = this.config.core.enabledApps; + + return enabledApps.includes(app); + } + + static async isCookieDomainAllowed(domain: string): Promise { + await this.loadConfig(); + + const allowedDomains = this.config.auth.allowedCookieDomains; + + if (allowedDomains.length === 0) { + return true; + } + + return allowedDomains.includes(domain); + } + + static async isCookieDomainSecurityDisabled(): Promise { + await this.loadConfig(); + + return this.config.auth.skipCookieDomainSecurity; + } + + static async isEmailVerificationEnabled(): Promise { + await this.loadConfig(); + + return this.config.auth.enableEmailVerification; + } + + static async isForeverSignupEnabled(): Promise { + await this.loadConfig(); + + return this.config.auth.enableForeverSignup; + } + + static async getFilesRootPath(): Promise { + await this.loadConfig(); + + const filesRootPath = `${Deno.cwd()}/${this.config.files.rootPath}`; + + return filesRootPath; + } } diff --git a/lib/models/files.ts b/lib/models/files.ts index 32f3f4b..fc247e6 100644 --- a/lib/models/files.ts +++ b/lib/models/files.ts @@ -2,15 +2,15 @@ import { join } from 'std/path/join.ts'; import { resolve } from 'std/path/resolve.ts'; import { lookup } from 'mrmime'; -import { getFilesRootPath } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { Directory, DirectoryFile } from '/lib/types.ts'; import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; export class DirectoryModel { static async list(userId: string, path: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, path); + await ensureUserPathIsValidAndSecurelyAccessible(userId, path); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); const directories: Directory[] = []; @@ -40,9 +40,9 @@ export class DirectoryModel { } static async create(userId: string, path: string, name: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); try { await Deno.mkdir(join(rootPath, name), { recursive: true }); @@ -72,7 +72,7 @@ export class DirectoryModel { userId: string, searchTerm: string, ): Promise<{ success: boolean; directories: Directory[] }> { - const rootPath = join(getFilesRootPath(), userId); + const rootPath = join(await AppConfig.getFilesRootPath(), userId); const directories: Directory[] = []; @@ -142,9 +142,9 @@ export class DirectoryModel { export class FileModel { static async list(userId: string, path: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, path); + await ensureUserPathIsValidAndSecurelyAccessible(userId, path); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); const files: DirectoryFile[] = []; @@ -177,9 +177,9 @@ export class FileModel { name: string, contents: string | ArrayBuffer, ): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); try { // Ensure the directory exist, if being requested @@ -210,9 +210,9 @@ export class FileModel { name: string, contents: string, ): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); try { await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false }); @@ -229,9 +229,9 @@ export class FileModel { path: string, name?: string, ): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name || '')); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name || '')); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); try { const stat = await Deno.stat(join(rootPath, name || '')); @@ -277,7 +277,7 @@ export class FileModel { userId: string, searchTerm: string, ): Promise<{ success: boolean; files: DirectoryFile[] }> { - const rootPath = join(getFilesRootPath(), userId); + const rootPath = join(await AppConfig.getFilesRootPath(), userId); const files: DirectoryFile[] = []; @@ -348,7 +348,7 @@ export class FileModel { userId: string, searchTerm: string, ): Promise<{ success: boolean; files: DirectoryFile[] }> { - const rootPath = join(getFilesRootPath(), userId); + const rootPath = join(await AppConfig.getFilesRootPath(), userId); const files: DirectoryFile[] = []; @@ -421,8 +421,8 @@ export class FileModel { * @param userId - The user ID * @param path - The relative path (user-provided) to check */ -export function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): void { - const userRootPath = join(getFilesRootPath(), userId, '/'); +export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): Promise { + const userRootPath = join(await AppConfig.getFilesRootPath(), userId, '/'); const fullPath = join(userRootPath, path); @@ -434,9 +434,9 @@ export function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: } async function getPathEntries(userId: string, path: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, path); + await ensureUserPathIsValidAndSecurelyAccessible(userId, path); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); // Ensure the user directory exists if (path === '/') { @@ -478,11 +478,11 @@ async function renameDirectoryOrFile( oldName: string, newName: string, ): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName)); - ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName)); - const oldRootPath = join(getFilesRootPath(), userId, oldPath); - const newRootPath = join(getFilesRootPath(), userId, newPath); + const oldRootPath = join(await AppConfig.getFilesRootPath(), userId, oldPath); + const newRootPath = join(await AppConfig.getFilesRootPath(), userId, newPath); try { await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName)); @@ -495,15 +495,15 @@ async function renameDirectoryOrFile( } async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - const rootPath = join(getFilesRootPath(), userId, path); + const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); try { if (path.startsWith(TRASH_PATH)) { await Deno.remove(join(rootPath, name), { recursive: true }); } else { - const trashPath = join(getFilesRootPath(), userId, TRASH_PATH); + const trashPath = join(await AppConfig.getFilesRootPath(), userId, TRASH_PATH); await Deno.rename(join(rootPath, name), join(trashPath, name)); } } catch (error) { diff --git a/lib/models/user.ts b/lib/models/user.ts index 885e9ba..28f9df9 100644 --- a/lib/models/user.ts +++ b/lib/models/user.ts @@ -1,7 +1,7 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { User, UserSession, VerificationCode } from '/lib/types.ts'; import { generateRandomCode } from '/lib/utils/misc.ts'; -import { isEmailEnabled, isForeverSignupEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; const db = new Database(); @@ -35,7 +35,7 @@ export class UserModel { } static async create(email: User['email'], hashedPassword: User['hashed_password']) { - const trialDays = isForeverSignupEnabled() ? 36_525 : 30; + const trialDays = await AppConfig.isForeverSignupEnabled() ? 36_525 : 30; const now = new Date(); const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays)); @@ -45,7 +45,7 @@ export class UserModel { updated_at: now.toISOString(), }; - const extra: User['extra'] = { is_email_verified: isEmailEnabled() ? false : true }; + const extra: User['extra'] = { is_email_verified: (await AppConfig.isEmailVerificationEnabled()) ? false : true }; // First signup will be an admin "forever" if (!(await this.isThereAnAdmin())) { @@ -65,7 +65,7 @@ export class UserModel { [ email, JSON.stringify(subscription), - extra.is_admin || isForeverSignupEnabled() ? 'active' : 'trial', + (extra.is_admin || (await AppConfig.isForeverSignupEnabled())) ? 'active' : 'trial', hashedPassword, JSON.stringify(extra), ], diff --git a/lib/providers/brevo.ts b/lib/providers/brevo.ts index 1908c14..6bc5041 100644 --- a/lib/providers/brevo.ts +++ b/lib/providers/brevo.ts @@ -1,11 +1,11 @@ import 'std/dotenv/load.ts'; -import { helpEmail } from '/lib/utils/misc.ts'; +import { AppConfig } from '/lib/config.ts'; const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || ''; enum BrevoTemplateId { - BEWCLOUD_VERIFY_EMAIL = 20, + BEWCLOUD_VERIFY_EMAIL = 20, // NOTE: This will likely be different in your own Brevo account } interface BrevoResponse { @@ -43,6 +43,9 @@ async function sendEmailWithTemplate( attachments: BrevoRequestBody['attachment'] = [], cc?: string, ) { + const config = await AppConfig.getConfig(); + const helpEmail = config.visuals.helpEmail; + const email: BrevoRequestBody = { templateId, params: data, diff --git a/lib/types.ts b/lib/types.ts index 39d6a81..b0f2aa5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -141,3 +141,40 @@ export const currencyMap = new Map([ ['¥', 'JPY'], ['₹', 'INR'], ]); + +export type PartialDeep = (T extends (infer U)[] ? PartialDeep[] : { [P in keyof T]?: PartialDeep }) | T; + +export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses'; + +export interface Config { + auth: { + /** The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" */ + 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 */ + allowSignups: boolean; + /** If true, email verification will be required for signups (using Brevo) */ + enableEmailVerification: boolean; + /** If true, all signups become active for 100 years */ + enableForeverSignup: boolean; + /** Can be set to allow more than the baseUrl's domain for session cookies */ + allowedCookieDomains: string[]; + /** 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: boolean; + }; + files: { + /** The root-relative root path for files, i.e. "data-files" */ + rootPath: string; + }; + core: { + /** dashboard and files cannot be disabled */ + enabledApps: OptionalApp[]; + }; + visuals: { + /** An override title of the application. Empty shows the default title. */ + title: string; + /** An override description of the application. Empty shows the default description. */ + description: string; + /** The email address to contact for help. Empty will disable/hide the "need help" sections. */ + helpEmail: string; + }; +} diff --git a/lib/utils/misc.ts b/lib/utils/misc.ts index 31c305e..a1d304d 100644 --- a/lib/utils/misc.ts +++ b/lib/utils/misc.ts @@ -1,28 +1,6 @@ import { currencyMap } from '/lib/types.ts'; import { SupportedCurrencySymbol } from '/lib/types.ts'; -let BASE_URL = typeof window !== 'undefined' && window.location - ? `${window.location.protocol}//${window.location.host}` - : ''; -let CUSTOM_TITLE = ''; -let CUSTOM_DESCRIPTION = ''; -let HELP_EMAIL = ''; - -if (typeof Deno !== 'undefined') { - await import('std/dotenv/load.ts'); - - BASE_URL = Deno.env.get('BASE_URL') || ''; - - CUSTOM_TITLE = Deno.env.get('CUSTOM_TITLE') || ''; - CUSTOM_DESCRIPTION = Deno.env.get('CUSTOM_DESCRIPTION') || ''; - HELP_EMAIL = Deno.env.get('HELP_EMAIL') || ''; -} - -export const baseUrl = BASE_URL || 'http://localhost:8000'; -export const defaultTitle = CUSTOM_TITLE || 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud'; -export const defaultDescription = CUSTOM_DESCRIPTION || `Have your files under your own control.`; -export const helpEmail = HELP_EMAIL; - export function isRunningLocally(request: Request): boolean { try { const url = new URL(request.url); diff --git a/routes/_app.tsx b/routes/_app.tsx index 1fcb964..71ecabc 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,12 +1,19 @@ import { PageProps } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { defaultDescription, defaultTitle } from '/lib/utils/misc.ts'; +import { AppConfig } from '/lib/config.ts'; + import Header from '/components/Header.tsx'; interface Data {} -export default function App({ route, Component, state }: PageProps) { +export default async function App(_request: Request, { route, Component, state }: PageProps) { + 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; + return ( @@ -22,7 +29,7 @@ export default function App({ route, Component, state }: PageProps -
+
diff --git a/routes/dav.tsx b/routes/dav.tsx index a77765c..809ba68 100644 --- a/routes/dav.tsx +++ b/routes/dav.tsx @@ -3,7 +3,7 @@ import { join } from 'std/path/join.ts'; import { parse, stringify } from 'xml'; import { FreshContextState } from '/lib/types.ts'; -import { getFilesRootPath } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import Locker from '/lib/interfaces/locker.ts'; import { addDavPrefixToKeys, @@ -37,7 +37,7 @@ export const handler: Handler = async (request, context const userId = context.state.user.id; - const rootPath = join(getFilesRootPath(), userId); + const rootPath = join(await AppConfig.getFilesRootPath(), userId); if (request.method === 'OPTIONS') { const headers = new Headers({ @@ -76,7 +76,7 @@ export const handler: Handler = async (request, context if (request.method === 'DELETE') { try { - ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await Deno.remove(join(rootPath, filePath)); @@ -94,7 +94,7 @@ export const handler: Handler = async (request, context const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body; try { - ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); const newFile = await Deno.open(join(rootPath, filePath), { create: true, @@ -116,8 +116,8 @@ export const handler: Handler = async (request, context const newFilePath = request.headers.get('destination'); if (newFilePath) { try { - ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); - ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); return new Response('Created', { status: 201 }); @@ -133,8 +133,8 @@ export const handler: Handler = async (request, context const newFilePath = request.headers.get('destination'); if (newFilePath) { try { - ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); - ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); + await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); return new Response('Created', { status: 201 }); @@ -146,7 +146,7 @@ export const handler: Handler = async (request, context if (request.method === 'MKCOL') { try { - ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await Deno.mkdir(join(rootPath, filePath), { recursive: true }); return new Response('Created', { status: 201 }); } catch (error) { @@ -223,7 +223,7 @@ export const handler: Handler = async (request, context const properties = getPropertyNames(parsedXml); - ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth); diff --git a/routes/expenses.tsx b/routes/expenses.tsx index 24fdb3b..4d5acba 100644 --- a/routes/expenses.tsx +++ b/routes/expenses.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts'; -import { isAppEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { BudgetModel, ExpenseModel, generateMonthlyBudgetsAndExpenses } from '/lib/models/expenses.ts'; import ExpensesWrapper from '/islands/expenses/ExpensesWrapper.tsx'; @@ -18,7 +18,7 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - if (!isAppEnabled('expenses')) { + if (!(await AppConfig.isAppEnabled('expenses'))) { return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } }); } diff --git a/routes/files.tsx b/routes/files.tsx index 1967836..bcd6d7d 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -2,12 +2,14 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; import { DirectoryModel, FileModel } from '/lib/models/files.ts'; +import { AppConfig } from '/lib/config.ts'; import FilesWrapper from '/islands/files/FilesWrapper.tsx'; interface Data { userDirectories: Directory[]; userFiles: DirectoryFile[]; currentPath: string; + baseUrl: string; } export const handler: Handlers = { @@ -16,6 +18,8 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } + const baseUrl = (await AppConfig.getConfig()).auth.baseUrl; + const searchParams = new URL(request.url).searchParams; let currentPath = searchParams.get('path') || '/'; @@ -34,7 +38,7 @@ export const handler: Handlers = { const userFiles = await FileModel.list(context.state.user.id, currentPath); - return await context.render({ userDirectories, userFiles, currentPath }); + return await context.render({ userDirectories, userFiles, currentPath, baseUrl }); }, }; @@ -45,6 +49,7 @@ export default function FilesPage({ data }: PageProps) initialDirectories={data.userDirectories} initialFiles={data.userFiles} initialPath={data.currentPath} + baseUrl={data.baseUrl} /> ); diff --git a/routes/login.tsx b/routes/login.tsx index 46860fc..c4bf345 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -1,18 +1,20 @@ import { Handlers, PageProps } from 'fresh/server.ts'; -import { generateHash, helpEmail, validateEmail } from '/lib/utils/misc.ts'; +import { generateHash, validateEmail } from '/lib/utils/misc.ts'; import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import { FreshContextState } from '/lib/types.ts'; -import { isEmailEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; interface Data { error?: string; notice?: string; email?: string; formData?: FormData; + isEmailVerificationEnabled: boolean; + helpEmail: string; } export const handler: Handlers = { @@ -21,6 +23,9 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); } + const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const searchParams = new URL(request.url).searchParams; const formData = new FormData(); @@ -31,20 +36,23 @@ export const handler: Handlers = { email = searchParams.get('email') || ''; formData.set('email', email); - if (isEmailEnabled()) { + if (await AppConfig.isEmailVerificationEnabled()) { notice = `You have received a code in your email. Use it to verify your email and login.`; } else { notice = `Your account was created successfully. Login below.`; } } - return await context.render({ notice, email, formData }); + return await context.render({ notice, email, formData, isEmailVerificationEnabled, helpEmail }); }, async POST(request, context) { if (context.state.user) { return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); } + const isEmailVerificationEnabled = await AppConfig.isEmailVerificationEnabled(); + const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const formData = await request.clone().formData(); const email = getFormDataField(formData, 'email'); @@ -67,7 +75,7 @@ export const handler: Handlers = { throw new Error('Email not found or invalid password.'); } - if (!isEmailEnabled() && !user.extra.is_email_verified) { + if (!(await AppConfig.isEmailVerificationEnabled()) && !user.extra.is_email_verified) { user.extra.is_email_verified = true; await UserModel.update(user); @@ -94,7 +102,13 @@ export const handler: Handlers = { return createSessionResponse(request, user, { urlToRedirectTo: `/` }); } catch (error) { console.error(error); - return await context.render({ error: (error as Error).toString(), email, formData }); + return await context.render({ + error: (error as Error).toString(), + email, + formData, + isEmailVerificationEnabled, + helpEmail, + }); } }, }; @@ -150,16 +164,17 @@ export default function Login({ data }: PageProps) { {data?.notice ? (
-

{isEmailEnabled() ? 'Verify your email!' : 'Account created!'}

+

{data?.isEmailVerificationEnabled ? 'Verify your email!' : 'Account created!'}

{data?.notice}

) : null}
- {formFields(data?.email, data?.notice?.includes('verify your email') && isEmailEnabled()).map((field) => - generateFieldHtml(field, data?.formData || new FormData()) - )} + {formFields( + data?.email, + data?.notice?.includes('verify your email') && data?.isEmailVerificationEnabled, + ).map((field) => generateFieldHtml(field, data?.formData || new FormData()))}
@@ -173,14 +188,14 @@ export default function Login({ data }: PageProps) { .

- {helpEmail !== '' + {data?.helpEmail !== '' ? ( <>

Need help?

If you're having any issues or have any questions,{' '} - please reach out + please reach out .

diff --git a/routes/news.tsx b/routes/news.tsx index 923c364..222b56e 100644 --- a/routes/news.tsx +++ b/routes/news.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { FreshContextState, NewsFeedArticle } from '/lib/types.ts'; -import { isAppEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { ArticleModel } from '/lib/models/news.ts'; import Articles from '/islands/news/Articles.tsx'; @@ -15,7 +15,7 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - if (!isAppEnabled('news')) { + if (!(await AppConfig.isAppEnabled('news'))) { return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } }); } diff --git a/routes/notes.tsx b/routes/notes.tsx index cc56728..977e8db 100644 --- a/routes/notes.tsx +++ b/routes/notes.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { isAppEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import NotesWrapper from '/islands/notes/NotesWrapper.tsx'; @@ -17,7 +17,7 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - if (!isAppEnabled('notes')) { + if (!(await AppConfig.isAppEnabled('notes'))) { return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } }); } diff --git a/routes/photos.tsx b/routes/photos.tsx index cba2986..250aad7 100644 --- a/routes/photos.tsx +++ b/routes/photos.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { isAppEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import { PHOTO_EXTENSIONS } from '/lib/utils/photos.ts'; import PhotosWrapper from '/islands/photos/PhotosWrapper.tsx'; @@ -18,7 +18,7 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - if (!isAppEnabled('photos')) { + if (!(await AppConfig.isAppEnabled('photos'))) { return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } }); } diff --git a/routes/settings.tsx b/routes/settings.tsx index 0b9896e..86ce4d4 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -6,7 +6,7 @@ import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; import { getFormDataField } from '/lib/form-utils.tsx'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; -import { isEmailEnabled } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import Settings, { Action, actionWords } from '/islands/Settings.tsx'; interface Data { @@ -20,6 +20,8 @@ interface Data { }; formData: Record; currency?: SupportedCurrencySymbol; + isExpensesAppEnabled: boolean; + helpEmail: string; } export const handler: Handlers = { @@ -28,9 +30,14 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } + const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses'); + const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + return await context.render({ formData: {}, currency: context.state.user.extra.expenses_currency, + isExpensesAppEnabled, + helpEmail, }); }, async POST(request, context) { @@ -38,6 +45,9 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } + const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses'); + const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + let action: Action = 'change-email'; let errorTitle = ''; let errorMessage = ''; @@ -72,7 +82,7 @@ export const handler: Handlers = { throw new Error('Email is already in use.'); } - if (action === 'change-email' && isEmailEnabled()) { + if (action === 'change-email' && (await AppConfig.isEmailVerificationEnabled())) { const verificationCode = await VerificationCodeModel.create(user, email, 'email'); await sendVerifyEmailEmail(email, verificationCode); @@ -80,7 +90,7 @@ export const handler: Handlers = { successTitle = 'Verify your email!'; successMessage = 'You have received a code in your new email. Use it to verify it here.'; } else { - if (isEmailEnabled()) { + if (await AppConfig.isEmailVerificationEnabled()) { const code = getFormDataField(formData, 'verification-code'); await VerificationCodeModel.validate(user, email, code, 'email'); @@ -178,6 +188,8 @@ export const handler: Handlers = { notice, formData: convertFormDataToObject(formData), currency: user.extra.expenses_currency, + isExpensesAppEnabled, + helpEmail, }); } catch (error) { console.error(error); @@ -188,6 +200,8 @@ export const handler: Handlers = { error: { title: errorTitle, message: errorMessage }, formData: convertFormDataToObject(formData), currency: user.extra.expenses_currency, + isExpensesAppEnabled, + helpEmail, }); } }, @@ -196,7 +210,14 @@ export const handler: Handlers = { export default function SettingsPage({ data }: PageProps) { return (
- +
); } diff --git a/routes/signup.tsx b/routes/signup.tsx index 1365698..9aa2ad2 100644 --- a/routes/signup.tsx +++ b/routes/signup.tsx @@ -1,11 +1,11 @@ import { Handlers, PageProps } from 'fresh/server.ts'; -import { generateHash, helpEmail, validateEmail } from '/lib/utils/misc.ts'; +import { generateHash, validateEmail } from '/lib/utils/misc.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; -import { isEmailEnabled, isSignupAllowed } from '/lib/config.ts'; +import { AppConfig } from '/lib/config.ts'; import { FreshContextState } from '/lib/types.ts'; interface Data { @@ -13,6 +13,7 @@ interface Data { notice?: string; email?: string; formData?: FormData; + helpEmail: string; } export const handler: Handlers = { @@ -21,6 +22,8 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); } + const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const searchParams = new URL(request.url).searchParams; let notice = ''; @@ -29,18 +32,20 @@ export const handler: Handlers = { notice = `Your account and all its data has been deleted.`; } - return await context.render({ notice }); + return await context.render({ notice, helpEmail }); }, async POST(request, context) { if (context.state.user) { return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); } + const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; + const formData = await request.clone().formData(); const email = getFormDataField(formData, 'email'); try { - if (!(await isSignupAllowed())) { + if (!(await AppConfig.isSignupAllowed())) { throw new Error(`Signups are not allowed.`); } @@ -64,7 +69,7 @@ export const handler: Handlers = { const user = await UserModel.create(email, hashedPassword); - if (isEmailEnabled()) { + if (await AppConfig.isEmailVerificationEnabled()) { const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); await sendVerifyEmailEmail(user.email, verificationCode); @@ -76,7 +81,7 @@ export const handler: Handlers = { }); } catch (error) { console.error(error); - return await context.render({ error: (error as Error).toString(), email, formData }); + return await context.render({ error: (error as Error).toString(), email, formData, helpEmail }); } }, }; @@ -144,14 +149,14 @@ export default function Signup({ data }: PageProps) { .

- {helpEmail !== '' + {data?.helpEmail !== '' ? ( <>

Need help?

If you're having any issues or have any questions,{' '} - please reach out + please reach out .