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
This commit is contained in:
Bruno Bernardino
2025-05-25 15:48:53 +01:00
committed by GitHub
parent 69142973d8
commit e337859a22
30 changed files with 443 additions and 198 deletions

View File

@@ -11,3 +11,4 @@ README.md
node_modules node_modules
_fresh _fresh
data-files data-files
bewcloud.config.sample.ts

View File

@@ -1,5 +1,4 @@
PORT=8000 PORT=8000
BASE_URL="http://localhost:8000"
POSTGRESQL_HOST="postgresql" # docker container name or external hostname/IP POSTGRESQL_HOST="postgresql" # docker container name or external hostname/IP
POSTGRESQL_USER="postgres" POSTGRESQL_USER="postgres"
@@ -12,15 +11,3 @@ JWT_SECRET="fake"
PASSWORD_SALT="fake" PASSWORD_SALT="fake"
BREVO_API_KEY="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

View File

@@ -30,4 +30,4 @@ jobs:
SSH_KEY: ${{ secrets.SSH_KEY }} SSH_KEY: ${{ secrets.SSH_KEY }}
- name: Deploy via SSH - 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'

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ db/
# Files # Files
data-files/ data-files/
# Config
bewcloud.config.ts

View File

@@ -16,28 +16,31 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github
Or on your own machine: 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 ```sh
$ mkdir data-files # local directory for storing user-uploaded files $ 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 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). Alternatively, check the [Development section below](#development).
> [!IMPORTANT] > [!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] > [!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 ## Development
### Requirements ### 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. - 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. - For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed.

26
bewcloud.config.sample.ts Normal file
View File

@@ -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<Config> = {
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;

View File

@@ -1,11 +1,11 @@
import { Head } from 'fresh/runtime.ts'; import { Head } from 'fresh/runtime.ts';
import { User } from '/lib/types.ts'; import { OptionalApp, User } from '/lib/types.ts';
import { isAppEnabled } from '/lib/config.ts';
interface Data { interface Data {
route: string; route: string;
user?: User; user?: User;
enabledApps: OptionalApp[];
} }
interface MenuItem { interface MenuItem {
@@ -13,7 +13,7 @@ interface MenuItem {
label: string; 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 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'; 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', url: '/dashboard',
label: 'Dashboard', label: 'Dashboard',
}, },
isAppEnabled('news') enabledApps.includes('news')
? { ? {
url: '/news', url: '/news',
label: 'News', label: 'News',
@@ -38,19 +38,19 @@ export default function Header({ route, user }: Data) {
url: '/files', url: '/files',
label: 'Files', label: 'Files',
}, },
isAppEnabled('notes') enabledApps.includes('notes')
? { ? {
url: '/notes', url: '/notes',
label: 'Notes', label: 'Notes',
} }
: null, : null,
isAppEnabled('photos') enabledApps.includes('photos')
? { ? {
url: '/photos', url: '/photos',
label: 'Photos', label: 'Photos',
} }
: null, : null,
isAppEnabled('expenses') enabledApps.includes('expenses')
? { ? {
url: '/expenses', url: '/expenses',
label: 'Expenses', label: 'Expenses',

View File

@@ -1,7 +1,6 @@
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 { baseUrl } from '/lib/utils/misc.ts';
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx'; 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 RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx'; import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
@@ -33,9 +32,10 @@ interface MainFilesProps {
initialDirectories: Directory[]; initialDirectories: Directory[];
initialFiles: DirectoryFile[]; initialFiles: DirectoryFile[];
initialPath: string; initialPath: string;
baseUrl: string;
} }
export default function MainFiles({ initialDirectories, initialFiles, initialPath }: MainFilesProps) { export default function MainFiles({ initialDirectories, initialFiles, initialPath, baseUrl }: MainFilesProps) {
const isAdding = useSignal<boolean>(false); const isAdding = useSignal<boolean>(false);
const isUploading = useSignal<boolean>(false); const isUploading = useSignal<boolean>(false);
const isDeleting = useSignal<boolean>(false); const isDeleting = useSignal<boolean>(false);

View File

@@ -1,10 +1,10 @@
import { Cron } from 'https://deno.land/x/croner@8.1.2/dist/croner.js'; 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 { cleanupSessions } from './sessions.ts';
import { cleanupOldArticles, fetchNewArticles } from './news.ts'; import { cleanupOldArticles, fetchNewArticles } from './news.ts';
export function startCrons() { export async function startCrons() {
new Cron( new Cron(
// At 03:06 every day. // At 03:06 every day.
'6 3 * * *', '6 3 * * *',
@@ -15,13 +15,13 @@ export function startCrons() {
async () => { async () => {
await cleanupSessions(); await cleanupSessions();
if (isAppEnabled('news')) { if (await AppConfig.isAppEnabled('news')) {
await cleanupOldArticles(); await cleanupOldArticles();
} }
}, },
); );
if (isAppEnabled('news')) { if (await AppConfig.isAppEnabled('news')) {
new Cron( new Cron(
// Every 30 minutes. // Every 30 minutes.
'*/30 * * * *', '*/30 * * * *',

View File

@@ -1,6 +1,6 @@
services: services:
website: 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 restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 127.0.0.1:8000:8000

View File

@@ -6,7 +6,7 @@ import { startCrons } from '/crons/index.ts';
const isBuildMode = Deno.args.includes('build'); const isBuildMode = Deno.args.includes('build');
if (!isBuildMode) { if (!isBuildMode) {
startCrons(); await startCrons();
} }
export default defineConfig({ export default defineConfig({

View File

@@ -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 { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts'; import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts';
import { isAppEnabled } from '/lib/config.ts';
interface SettingsProps { interface SettingsProps {
formData: Record<string, any>; formData: Record<string, any>;
@@ -14,6 +13,8 @@ interface SettingsProps {
message: string; message: string;
}; };
currency?: SupportedCurrencySymbol; currency?: SupportedCurrencySymbol;
isExpensesAppEnabled: boolean;
helpEmail: string;
} }
export type Action = export type Action =
@@ -119,7 +120,9 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr
return fields; 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 formData = convertObjectToFormData(formDataObject);
const action = getFormDataField(formData, 'action') as Action; const action = getFormDataField(formData, 'action') as Action;
@@ -174,7 +177,7 @@ export default function Settings({ formData: formDataObject, error, notice, curr
</section> </section>
</form> </form>
{isAppEnabled('expenses') {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> <h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your currency</h2>

View File

@@ -5,17 +5,19 @@ interface FilesWrapperProps {
initialDirectories: Directory[]; initialDirectories: Directory[];
initialFiles: DirectoryFile[]; initialFiles: DirectoryFile[];
initialPath: string; 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 // 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( export default function FilesWrapper(
{ initialDirectories, initialFiles, initialPath }: FilesWrapperProps, { initialDirectories, initialFiles, initialPath, baseUrl }: FilesWrapperProps,
) { ) {
return ( return (
<MainFiles <MainFiles
initialDirectories={initialDirectories} initialDirectories={initialDirectories}
initialFiles={initialFiles} initialFiles={initialFiles}
initialPath={initialPath} initialPath={initialPath}
baseUrl={baseUrl}
/> />
); );
} }

View File

@@ -3,10 +3,10 @@ import { decodeBase64 } from 'std/encoding/base64.ts';
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
import 'std/dotenv/load.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 { User, UserSession } from './types.ts';
import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.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') || ''; 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') || '';
@@ -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); const textToData = (text: string) => new TextEncoder().encode(text);
@@ -51,10 +51,13 @@ async function verifyAuthJwt(key: CryptoKey, jwt: string) {
throw new Error('Invalid JWT'); throw new Error('Invalid JWT');
} }
function resolveCookieDomain(request: Request) { async function resolveCookieDomain(request: Request) {
if (!isBaseUrlAnIp() || isRunningLocally(request)) { const config = await AppConfig.getConfig();
const baseUrl = config.auth.baseUrl;
if (!isUrlAnIp(baseUrl) || isRunningLocally(request)) {
const domain = new URL(request.url).hostname; const domain = new URL(request.url).hostname;
if (isCookieDomainAllowed(domain)) { if (await AppConfig.isCookieDomainAllowed(domain)) {
return domain; return domain;
} }
return baseUrl.replace('https://', '').replace('http://', '').split(':')[0]; return baseUrl.replace('https://', '').replace('http://', '').split(':')[0];
@@ -170,10 +173,10 @@ export async function logoutUser(request: Request) {
secure: isRunningLocally(request) ? false : true, secure: isRunningLocally(request) ? false : true,
httpOnly: true, httpOnly: true,
sameSite: 'Lax', sameSite: 'Lax',
domain: resolveCookieDomain(request), domain: await resolveCookieDomain(request),
}; };
if (isCookieDomainSecurityDisabled()) { if (await AppConfig.isCookieDomainSecurityDisabled()) {
delete cookie.domain; delete cookie.domain;
} }
@@ -223,10 +226,10 @@ export async function createSessionCookie(
secure: isRunningLocally(request) ? false : true, secure: isRunningLocally(request) ? false : true,
httpOnly: true, httpOnly: true,
sameSite: 'Lax', sameSite: 'Lax',
domain: resolveCookieDomain(request), domain: await resolveCookieDomain(request),
}; };
if (isCookieDomainSecurityDisabled()) { if (await AppConfig.isCookieDomainSecurityDisabled()) {
delete cookie.domain; delete cookie.domain;
} }
@@ -251,10 +254,10 @@ export async function updateSessionCookie(
secure: isRunningLocally(request) ? false : true, secure: isRunningLocally(request) ? false : true,
httpOnly: true, httpOnly: true,
sameSite: 'Lax', sameSite: 'Lax',
domain: resolveCookieDomain(request), domain: await resolveCookieDomain(request),
}; };
if (isCookieDomainSecurityDisabled()) { if (await AppConfig.isCookieDomainSecurityDisabled()) {
delete cookie.domain; delete cookie.domain;
} }

View File

@@ -1,57 +1,203 @@
import 'std/dotenv/load.ts';
import { UserModel } from './models/user.ts'; import { UserModel } from './models/user.ts';
import { Config, OptionalApp } from './types.ts';
export async function isSignupAllowed() { export class AppConfig {
const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true'; private static config: Config;
const areThereAdmins = await UserModel.isThereAnAdmin(); private static getDefaultConfig(): Config {
return {
if (areSignupsAllowed || !areThereAdmins) { auth: {
return true; 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<Config> {
const defaultConfig = this.getDefaultConfig();
export function isAppEnabled(app: 'news' | 'notes' | 'photos' | 'expenses') { if (typeof Deno === 'undefined') {
const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') || '').split(',') as typeof app[]; return defaultConfig;
}
return enabledApps.includes(app); await import('std/dotenv/load.ts');
}
export function isCookieDomainAllowed(domain: string) { const baseUrl = Deno.env.get('BASE_URL') ?? defaultConfig.auth.baseUrl;
const allowedDomains = (Deno.env.get('CONFIG_ALLOWED_COOKIE_DOMAINS') || '').split(',') as typeof domain[]; 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 {
return true; ...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); private static async loadConfig(): Promise<void> {
} if (this.config) {
return;
export function isCookieDomainSecurityDisabled() { }
const isCookieDomainSecurityDisabled = Deno.env.get('CONFIG_SKIP_COOKIE_DOMAIN_SECURITY') === 'true';
let initialConfig = this.getDefaultConfig();
return isCookieDomainSecurityDisabled;
} if (
typeof Deno.env.get('BASE_URL') === 'string' || typeof Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'string' ||
export function isEmailEnabled() { typeof Deno.env.get('CONFIG_ENABLED_APPS') === 'string'
const areEmailsAllowed = Deno.env.get('CONFIG_ENABLE_EMAILS') === 'true'; ) {
console.warn(
return areEmailsAllowed; '\nDEPRECATION WARNING: .env file has config variables. This will be used but is deprecated. Please use the bewcloud.config.ts file instead.',
} );
export function isForeverSignupEnabled() { initialConfig = await this.getLegacyConfigFromEnv();
const areForeverAccountsEnabled = Deno.env.get('CONFIG_ENABLE_FOREVER_SIGNUP') === 'true'; }
return areForeverAccountsEnabled; const config: Config = {
} ...initialConfig,
};
export function getFilesRootPath() {
const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH') || ''; try {
const configFromFile: Config = (await import(`${Deno.cwd()}/bewcloud.config.ts`)).default;
const filesRootPath = `${Deno.cwd()}/${configRootPath}`;
this.config = {
return filesRootPath; ...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<Config> {
await this.loadConfig();
return this.config;
}
static async isSignupAllowed(): Promise<boolean> {
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<boolean> {
await this.loadConfig();
const enabledApps = this.config.core.enabledApps;
return enabledApps.includes(app);
}
static async isCookieDomainAllowed(domain: string): Promise<boolean> {
await this.loadConfig();
const allowedDomains = this.config.auth.allowedCookieDomains;
if (allowedDomains.length === 0) {
return true;
}
return allowedDomains.includes(domain);
}
static async isCookieDomainSecurityDisabled(): Promise<boolean> {
await this.loadConfig();
return this.config.auth.skipCookieDomainSecurity;
}
static async isEmailVerificationEnabled(): Promise<boolean> {
await this.loadConfig();
return this.config.auth.enableEmailVerification;
}
static async isForeverSignupEnabled(): Promise<boolean> {
await this.loadConfig();
return this.config.auth.enableForeverSignup;
}
static async getFilesRootPath(): Promise<string> {
await this.loadConfig();
const filesRootPath = `${Deno.cwd()}/${this.config.files.rootPath}`;
return filesRootPath;
}
} }

View File

@@ -2,15 +2,15 @@ import { join } from 'std/path/join.ts';
import { resolve } from 'std/path/resolve.ts'; import { resolve } from 'std/path/resolve.ts';
import { lookup } from 'mrmime'; import { lookup } from 'mrmime';
import { getFilesRootPath } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
export class DirectoryModel { export class DirectoryModel {
static async list(userId: string, path: string): Promise<Directory[]> { static async list(userId: string, path: string): Promise<Directory[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path); await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
const directories: Directory[] = []; const directories: Directory[] = [];
@@ -40,9 +40,9 @@ export class DirectoryModel {
} }
static async create(userId: string, path: string, name: string): Promise<boolean> { static async create(userId: string, path: string, name: string): Promise<boolean> {
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 { try {
await Deno.mkdir(join(rootPath, name), { recursive: true }); await Deno.mkdir(join(rootPath, name), { recursive: true });
@@ -72,7 +72,7 @@ export class DirectoryModel {
userId: string, userId: string,
searchTerm: string, searchTerm: string,
): Promise<{ success: boolean; directories: Directory[] }> { ): Promise<{ success: boolean; directories: Directory[] }> {
const rootPath = join(getFilesRootPath(), userId); const rootPath = join(await AppConfig.getFilesRootPath(), userId);
const directories: Directory[] = []; const directories: Directory[] = [];
@@ -142,9 +142,9 @@ export class DirectoryModel {
export class FileModel { export class FileModel {
static async list(userId: string, path: string): Promise<DirectoryFile[]> { static async list(userId: string, path: string): Promise<DirectoryFile[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path); await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
const files: DirectoryFile[] = []; const files: DirectoryFile[] = [];
@@ -177,9 +177,9 @@ export class FileModel {
name: string, name: string,
contents: string | ArrayBuffer, contents: string | ArrayBuffer,
): Promise<boolean> { ): Promise<boolean> {
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 { try {
// Ensure the directory exist, if being requested // Ensure the directory exist, if being requested
@@ -210,9 +210,9 @@ export class FileModel {
name: string, name: string,
contents: string, contents: string,
): Promise<boolean> { ): Promise<boolean> {
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 { try {
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false }); await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false });
@@ -229,9 +229,9 @@ export class FileModel {
path: string, path: string,
name?: string, name?: string,
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> { ): 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 { try {
const stat = await Deno.stat(join(rootPath, name || '')); const stat = await Deno.stat(join(rootPath, name || ''));
@@ -277,7 +277,7 @@ export class FileModel {
userId: string, userId: string,
searchTerm: string, searchTerm: string,
): Promise<{ success: boolean; files: DirectoryFile[] }> { ): Promise<{ success: boolean; files: DirectoryFile[] }> {
const rootPath = join(getFilesRootPath(), userId); const rootPath = join(await AppConfig.getFilesRootPath(), userId);
const files: DirectoryFile[] = []; const files: DirectoryFile[] = [];
@@ -348,7 +348,7 @@ export class FileModel {
userId: string, userId: string,
searchTerm: string, searchTerm: string,
): Promise<{ success: boolean; files: DirectoryFile[] }> { ): Promise<{ success: boolean; files: DirectoryFile[] }> {
const rootPath = join(getFilesRootPath(), userId); const rootPath = join(await AppConfig.getFilesRootPath(), userId);
const files: DirectoryFile[] = []; const files: DirectoryFile[] = [];
@@ -421,8 +421,8 @@ export class FileModel {
* @param userId - The user ID * @param userId - The user ID
* @param path - The relative path (user-provided) to check * @param path - The relative path (user-provided) to check
*/ */
export function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): void { export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): Promise<void> {
const userRootPath = join(getFilesRootPath(), userId, '/'); const userRootPath = join(await AppConfig.getFilesRootPath(), userId, '/');
const fullPath = join(userRootPath, path); const fullPath = join(userRootPath, path);
@@ -434,9 +434,9 @@ export function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path:
} }
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> { async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
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 // Ensure the user directory exists
if (path === '/') { if (path === '/') {
@@ -478,11 +478,11 @@ async function renameDirectoryOrFile(
oldName: string, oldName: string,
newName: string, newName: string,
): Promise<boolean> { ): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName)); await ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName));
ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName)); await ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName));
const oldRootPath = join(getFilesRootPath(), userId, oldPath); const oldRootPath = join(await AppConfig.getFilesRootPath(), userId, oldPath);
const newRootPath = join(getFilesRootPath(), userId, newPath); const newRootPath = join(await AppConfig.getFilesRootPath(), userId, newPath);
try { try {
await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName)); 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<boolean> { async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
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 { 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 });
} else { } 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)); await Deno.rename(join(rootPath, name), join(trashPath, name));
} }
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,7 @@
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 '/lib/utils/misc.ts';
import { isEmailEnabled, isForeverSignupEnabled } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
const db = new Database(); const db = new Database();
@@ -35,7 +35,7 @@ export class UserModel {
} }
static async create(email: User['email'], hashedPassword: User['hashed_password']) { 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 now = new Date();
const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays)); const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays));
@@ -45,7 +45,7 @@ export class UserModel {
updated_at: now.toISOString(), 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" // First signup will be an admin "forever"
if (!(await this.isThereAnAdmin())) { if (!(await this.isThereAnAdmin())) {
@@ -65,7 +65,7 @@ export class UserModel {
[ [
email, email,
JSON.stringify(subscription), JSON.stringify(subscription),
extra.is_admin || isForeverSignupEnabled() ? 'active' : 'trial', (extra.is_admin || (await AppConfig.isForeverSignupEnabled())) ? 'active' : 'trial',
hashedPassword, hashedPassword,
JSON.stringify(extra), JSON.stringify(extra),
], ],

View File

@@ -1,11 +1,11 @@
import 'std/dotenv/load.ts'; 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') || ''; const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || '';
enum BrevoTemplateId { enum BrevoTemplateId {
BEWCLOUD_VERIFY_EMAIL = 20, BEWCLOUD_VERIFY_EMAIL = 20, // NOTE: This will likely be different in your own Brevo account
} }
interface BrevoResponse { interface BrevoResponse {
@@ -43,6 +43,9 @@ async function sendEmailWithTemplate(
attachments: BrevoRequestBody['attachment'] = [], attachments: BrevoRequestBody['attachment'] = [],
cc?: string, cc?: string,
) { ) {
const config = await AppConfig.getConfig();
const helpEmail = config.visuals.helpEmail;
const email: BrevoRequestBody = { const email: BrevoRequestBody = {
templateId, templateId,
params: data, params: data,

View File

@@ -141,3 +141,40 @@ export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
['¥', 'JPY'], ['¥', 'JPY'],
['₹', 'INR'], ['₹', 'INR'],
]); ]);
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';
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;
};
}

View File

@@ -1,28 +1,6 @@
import { currencyMap } from '/lib/types.ts'; import { currencyMap } from '/lib/types.ts';
import { SupportedCurrencySymbol } 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 { export function isRunningLocally(request: Request): boolean {
try { try {
const url = new URL(request.url); const url = new URL(request.url);

View File

@@ -1,12 +1,19 @@
import { PageProps } from 'fresh/server.ts'; import { PageProps } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.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'; import Header from '/components/Header.tsx';
interface Data {} interface Data {}
export default function App({ route, Component, state }: PageProps<Data, FreshContextState>) { export default async function App(_request: Request, { route, Component, state }: PageProps<Data, FreshContextState>) {
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 ( return (
<html class='h-full bg-slate-800'> <html class='h-full bg-slate-800'>
<head> <head>
@@ -22,7 +29,7 @@ export default function App({ route, Component, state }: PageProps<Data, FreshCo
<link rel='manifest' href='/manifest.json' /> <link rel='manifest' href='/manifest.json' />
</head> </head>
<body class='h-full'> <body class='h-full'>
<Header route={route} user={state.user} /> <Header route={route} user={state.user} enabledApps={enabledApps} />
<Component /> <Component />
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@ import { join } from 'std/path/join.ts';
import { parse, stringify } from 'xml'; import { parse, stringify } from 'xml';
import { FreshContextState } from '/lib/types.ts'; 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 Locker from '/lib/interfaces/locker.ts';
import { import {
addDavPrefixToKeys, addDavPrefixToKeys,
@@ -37,7 +37,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const userId = context.state.user.id; const userId = context.state.user.id;
const rootPath = join(getFilesRootPath(), userId); const rootPath = join(await AppConfig.getFilesRootPath(), userId);
if (request.method === 'OPTIONS') { if (request.method === 'OPTIONS') {
const headers = new Headers({ const headers = new Headers({
@@ -76,7 +76,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'DELETE') { if (request.method === 'DELETE') {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await Deno.remove(join(rootPath, filePath)); await Deno.remove(join(rootPath, filePath));
@@ -94,7 +94,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body; const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body;
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const newFile = await Deno.open(join(rootPath, filePath), { const newFile = await Deno.open(join(rootPath, filePath), {
create: true, create: true,
@@ -116,8 +116,8 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const newFilePath = request.headers.get('destination'); const newFilePath = request.headers.get('destination');
if (newFilePath) { if (newFilePath) {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); await ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath));
await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
return new Response('Created', { status: 201 }); return new Response('Created', { status: 201 });
@@ -133,8 +133,8 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const newFilePath = request.headers.get('destination'); const newFilePath = request.headers.get('destination');
if (newFilePath) { if (newFilePath) {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); await ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath));
await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
return new Response('Created', { status: 201 }); return new Response('Created', { status: 201 });
@@ -146,7 +146,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'MKCOL') { if (request.method === 'MKCOL') {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await Deno.mkdir(join(rootPath, filePath), { recursive: true }); await Deno.mkdir(join(rootPath, filePath), { recursive: true });
return new Response('Created', { status: 201 }); return new Response('Created', { status: 201 });
} catch (error) { } catch (error) {
@@ -223,7 +223,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const properties = getPropertyNames(parsedXml); const properties = getPropertyNames(parsedXml);
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth); const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth);

View File

@@ -1,7 +1,7 @@
import { Handlers, PageProps } from 'fresh/server.ts'; import { Handlers, PageProps } from 'fresh/server.ts';
import { Budget, Expense, FreshContextState, SupportedCurrencySymbol } from '/lib/types.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 { BudgetModel, ExpenseModel, generateMonthlyBudgetsAndExpenses } from '/lib/models/expenses.ts';
import ExpensesWrapper from '/islands/expenses/ExpensesWrapper.tsx'; import ExpensesWrapper from '/islands/expenses/ExpensesWrapper.tsx';
@@ -18,7 +18,7 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); 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` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } });
} }

View File

@@ -2,12 +2,14 @@ import { Handlers, PageProps } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import { DirectoryModel, FileModel } from '/lib/models/files.ts';
import { AppConfig } from '/lib/config.ts';
import FilesWrapper from '/islands/files/FilesWrapper.tsx'; import FilesWrapper from '/islands/files/FilesWrapper.tsx';
interface Data { interface Data {
userDirectories: Directory[]; userDirectories: Directory[];
userFiles: DirectoryFile[]; userFiles: DirectoryFile[];
currentPath: string; currentPath: string;
baseUrl: string;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -16,6 +18,8 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
} }
const baseUrl = (await AppConfig.getConfig()).auth.baseUrl;
const searchParams = new URL(request.url).searchParams; const searchParams = new URL(request.url).searchParams;
let currentPath = searchParams.get('path') || '/'; let currentPath = searchParams.get('path') || '/';
@@ -34,7 +38,7 @@ export const handler: Handlers<Data, FreshContextState> = {
const userFiles = await FileModel.list(context.state.user.id, currentPath); 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<Data, FreshContextState>)
initialDirectories={data.userDirectories} initialDirectories={data.userDirectories}
initialFiles={data.userFiles} initialFiles={data.userFiles}
initialPath={data.currentPath} initialPath={data.currentPath}
baseUrl={data.baseUrl}
/> />
</main> </main>
); );

View File

@@ -1,18 +1,20 @@
import { Handlers, PageProps } from 'fresh/server.ts'; 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 { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts';
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts';
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
import { FreshContextState } from '/lib/types.ts'; import { FreshContextState } from '/lib/types.ts';
import { isEmailEnabled } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
interface Data { interface Data {
error?: string; error?: string;
notice?: string; notice?: string;
email?: string; email?: string;
formData?: FormData; formData?: FormData;
isEmailVerificationEnabled: boolean;
helpEmail: string;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -21,6 +23,9 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); 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 searchParams = new URL(request.url).searchParams;
const formData = new FormData(); const formData = new FormData();
@@ -31,20 +36,23 @@ export const handler: Handlers<Data, FreshContextState> = {
email = searchParams.get('email') || ''; email = searchParams.get('email') || '';
formData.set('email', 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.`; notice = `You have received a code in your email. Use it to verify your email and login.`;
} else { } else {
notice = `Your account was created successfully. Login below.`; 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) { async POST(request, context) {
if (context.state.user) { if (context.state.user) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); 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 formData = await request.clone().formData();
const email = getFormDataField(formData, 'email'); const email = getFormDataField(formData, 'email');
@@ -67,7 +75,7 @@ export const handler: Handlers<Data, FreshContextState> = {
throw new Error('Email not found or invalid password.'); 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; user.extra.is_email_verified = true;
await UserModel.update(user); await UserModel.update(user);
@@ -94,7 +102,13 @@ export const handler: Handlers<Data, FreshContextState> = {
return createSessionResponse(request, user, { urlToRedirectTo: `/` }); return createSessionResponse(request, user, { urlToRedirectTo: `/` });
} catch (error) { } catch (error) {
console.error(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, FreshContextState>) {
{data?.notice {data?.notice
? ( ? (
<section class='notification-success'> <section class='notification-success'>
<h3>{isEmailEnabled() ? 'Verify your email!' : 'Account created!'}</h3> <h3>{data?.isEmailVerificationEnabled ? 'Verify your email!' : 'Account created!'}</h3>
<p>{data?.notice}</p> <p>{data?.notice}</p>
</section> </section>
) )
: null} : null}
<form method='POST' class='mb-12'> <form method='POST' class='mb-12'>
{formFields(data?.email, data?.notice?.includes('verify your email') && isEmailEnabled()).map((field) => {formFields(
generateFieldHtml(field, data?.formData || new FormData()) data?.email,
)} data?.notice?.includes('verify your email') && data?.isEmailVerificationEnabled,
).map((field) => generateFieldHtml(field, data?.formData || new FormData()))}
<section class='flex justify-center mt-8 mb-4'> <section class='flex justify-center mt-8 mb-4'>
<button class='button' type='submit'>Login</button> <button class='button' type='submit'>Login</button>
</section> </section>
@@ -173,14 +188,14 @@ export default function Login({ data }: PageProps<Data, FreshContextState>) {
</strong>. </strong>.
</p> </p>
{helpEmail !== '' {data?.helpEmail !== ''
? ( ? (
<> <>
<h2 class='text-2xl mb-4 text-center'>Need help?</h2> <h2 class='text-2xl mb-4 text-center'>Need help?</h2>
<p class='text-center mt-2 mb-6'> <p class='text-center mt-2 mb-6'>
If you're having any issues or have any questions,{' '} If you're having any issues or have any questions,{' '}
<strong> <strong>
<a href={`mailto:${helpEmail}`}>please reach out</a> <a href={`mailto:${data?.helpEmail}`}>please reach out</a>
</strong>. </strong>.
</p> </p>
</> </>

View File

@@ -1,7 +1,7 @@
import { Handlers, PageProps } from 'fresh/server.ts'; import { Handlers, PageProps } from 'fresh/server.ts';
import { FreshContextState, NewsFeedArticle } from '/lib/types.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 { ArticleModel } from '/lib/models/news.ts';
import Articles from '/islands/news/Articles.tsx'; import Articles from '/islands/news/Articles.tsx';
@@ -15,7 +15,7 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); 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` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } });
} }

View File

@@ -1,7 +1,7 @@
import { Handlers, PageProps } from 'fresh/server.ts'; import { Handlers, PageProps } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.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 { DirectoryModel, FileModel } from '/lib/models/files.ts';
import NotesWrapper from '/islands/notes/NotesWrapper.tsx'; import NotesWrapper from '/islands/notes/NotesWrapper.tsx';
@@ -17,7 +17,7 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); 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` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } });
} }

View File

@@ -1,7 +1,7 @@
import { Handlers, PageProps } from 'fresh/server.ts'; import { Handlers, PageProps } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.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 { DirectoryModel, FileModel } from '/lib/models/files.ts';
import { PHOTO_EXTENSIONS } from '/lib/utils/photos.ts'; import { PHOTO_EXTENSIONS } from '/lib/utils/photos.ts';
import PhotosWrapper from '/islands/photos/PhotosWrapper.tsx'; import PhotosWrapper from '/islands/photos/PhotosWrapper.tsx';
@@ -18,7 +18,7 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); 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` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } });
} }

View File

@@ -6,7 +6,7 @@ import { UserModel, VerificationCodeModel } from '/lib/models/user.ts';
import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts';
import { getFormDataField } from '/lib/form-utils.tsx'; import { getFormDataField } from '/lib/form-utils.tsx';
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; 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'; import Settings, { Action, actionWords } from '/islands/Settings.tsx';
interface Data { interface Data {
@@ -20,6 +20,8 @@ interface Data {
}; };
formData: Record<string, any>; formData: Record<string, any>;
currency?: SupportedCurrencySymbol; currency?: SupportedCurrencySymbol;
isExpensesAppEnabled: boolean;
helpEmail: string;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -28,9 +30,14 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); 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({ return await context.render({
formData: {}, formData: {},
currency: context.state.user.extra.expenses_currency, currency: context.state.user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
}); });
}, },
async POST(request, context) { async POST(request, context) {
@@ -38,6 +45,9 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); 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 action: Action = 'change-email';
let errorTitle = ''; let errorTitle = '';
let errorMessage = ''; let errorMessage = '';
@@ -72,7 +82,7 @@ export const handler: Handlers<Data, FreshContextState> = {
throw new Error('Email is already in use.'); 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'); const verificationCode = await VerificationCodeModel.create(user, email, 'email');
await sendVerifyEmailEmail(email, verificationCode); await sendVerifyEmailEmail(email, verificationCode);
@@ -80,7 +90,7 @@ export const handler: Handlers<Data, FreshContextState> = {
successTitle = 'Verify your email!'; successTitle = 'Verify your email!';
successMessage = 'You have received a code in your new email. Use it to verify it here.'; successMessage = 'You have received a code in your new email. Use it to verify it here.';
} else { } else {
if (isEmailEnabled()) { if (await AppConfig.isEmailVerificationEnabled()) {
const code = getFormDataField(formData, 'verification-code'); const code = getFormDataField(formData, 'verification-code');
await VerificationCodeModel.validate(user, email, code, 'email'); await VerificationCodeModel.validate(user, email, code, 'email');
@@ -178,6 +188,8 @@ export const handler: Handlers<Data, FreshContextState> = {
notice, notice,
formData: convertFormDataToObject(formData), formData: convertFormDataToObject(formData),
currency: user.extra.expenses_currency, currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -188,6 +200,8 @@ export const handler: Handlers<Data, FreshContextState> = {
error: { title: errorTitle, message: errorMessage }, error: { title: errorTitle, message: errorMessage },
formData: convertFormDataToObject(formData), formData: convertFormDataToObject(formData),
currency: user.extra.expenses_currency, currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
}); });
} }
}, },
@@ -196,7 +210,14 @@ export const handler: Handlers<Data, FreshContextState> = {
export default function SettingsPage({ data }: PageProps<Data, FreshContextState>) { export default function SettingsPage({ data }: PageProps<Data, FreshContextState>) {
return ( return (
<main> <main>
<Settings formData={data?.formData} error={data?.error} notice={data?.notice} currency={data?.currency} /> <Settings
formData={data?.formData}
error={data?.error}
notice={data?.notice}
currency={data?.currency}
isExpensesAppEnabled={data?.isExpensesAppEnabled}
helpEmail={data?.helpEmail}
/>
</main> </main>
); );
} }

View File

@@ -1,11 +1,11 @@
import { Handlers, PageProps } from 'fresh/server.ts'; 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 { PASSWORD_SALT } from '/lib/auth.ts';
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts';
import { sendVerifyEmailEmail } from '/lib/providers/brevo.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'; import { FreshContextState } from '/lib/types.ts';
interface Data { interface Data {
@@ -13,6 +13,7 @@ interface Data {
notice?: string; notice?: string;
email?: string; email?: string;
formData?: FormData; formData?: FormData;
helpEmail: string;
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -21,6 +22,8 @@ export const handler: Handlers<Data, FreshContextState> = {
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
} }
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
const searchParams = new URL(request.url).searchParams; const searchParams = new URL(request.url).searchParams;
let notice = ''; let notice = '';
@@ -29,18 +32,20 @@ export const handler: Handlers<Data, FreshContextState> = {
notice = `Your account and all its data has been deleted.`; 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) { async POST(request, context) {
if (context.state.user) { if (context.state.user) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
} }
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
const formData = await request.clone().formData(); const formData = await request.clone().formData();
const email = getFormDataField(formData, 'email'); const email = getFormDataField(formData, 'email');
try { try {
if (!(await isSignupAllowed())) { if (!(await AppConfig.isSignupAllowed())) {
throw new Error(`Signups are not allowed.`); throw new Error(`Signups are not allowed.`);
} }
@@ -64,7 +69,7 @@ export const handler: Handlers<Data, FreshContextState> = {
const user = await UserModel.create(email, hashedPassword); const user = await UserModel.create(email, hashedPassword);
if (isEmailEnabled()) { if (await AppConfig.isEmailVerificationEnabled()) {
const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); const verificationCode = await VerificationCodeModel.create(user, user.email, 'email');
await sendVerifyEmailEmail(user.email, verificationCode); await sendVerifyEmailEmail(user.email, verificationCode);
@@ -76,7 +81,7 @@ export const handler: Handlers<Data, FreshContextState> = {
}); });
} catch (error) { } catch (error) {
console.error(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<Data, FreshContextState>) {
</strong>. </strong>.
</p> </p>
{helpEmail !== '' {data?.helpEmail !== ''
? ( ? (
<> <>
<h2 class='text-2xl mb-4 text-center'>Need help?</h2> <h2 class='text-2xl mb-4 text-center'>Need help?</h2>
<p class='text-center mt-2 mb-6'> <p class='text-center mt-2 mb-6'>
If you're having any issues or have any questions,{' '} If you're having any issues or have any questions,{' '}
<strong> <strong>
<a href={`mailto:${helpEmail}`}>please reach out</a> <a href={`mailto:${data?.helpEmail}`}>please reach out</a>
</strong>. </strong>.
</p> </p>
</> </>