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
_fresh
data-files
bewcloud.config.sample.ts

View File

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

View File

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

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ db/
# 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:
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.

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 { 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',

View File

@@ -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<boolean>(false);
const isUploading = 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 { 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 * * * *',

View File

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

View File

@@ -6,7 +6,7 @@ import { startCrons } from '/crons/index.ts';
const isBuildMode = Deno.args.includes('build');
if (!isBuildMode) {
startCrons();
await startCrons();
}
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 { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts';
import { isAppEnabled } from '/lib/config.ts';
interface SettingsProps {
formData: Record<string, any>;
@@ -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
</section>
</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>

View File

@@ -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 (
<MainFiles
initialDirectories={initialDirectories}
initialFiles={initialFiles}
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 '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;
}

View File

@@ -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<Config> {
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<void> {
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<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 { 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<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[] = [];
@@ -40,9 +40,9 @@ export class DirectoryModel {
}
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 {
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<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[] = [];
@@ -177,9 +177,9 @@ export class FileModel {
name: string,
contents: string | ArrayBuffer,
): 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 {
// Ensure the directory exist, if being requested
@@ -210,9 +210,9 @@ export class FileModel {
name: string,
contents: 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 {
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<void> {
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<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
if (path === '/') {
@@ -478,11 +478,11 @@ async function renameDirectoryOrFile(
oldName: string,
newName: string,
): Promise<boolean> {
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<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 {
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) {

View File

@@ -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),
],

View File

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

View File

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

View File

@@ -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<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 (
<html class='h-full bg-slate-800'>
<head>
@@ -22,7 +29,7 @@ export default function App({ route, Component, state }: PageProps<Data, FreshCo
<link rel='manifest' href='/manifest.json' />
</head>
<body class='h-full'>
<Header route={route} user={state.user} />
<Header route={route} user={state.user} enabledApps={enabledApps} />
<Component />
</body>
</html>

View File

@@ -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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = async (request, context
const properties = getPropertyNames(parsedXml);
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth);

View File

@@ -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<Data, FreshContextState> = {
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` } });
}

View File

@@ -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<Data, FreshContextState> = {
@@ -16,6 +18,8 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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}
initialFiles={data.userFiles}
initialPath={data.currentPath}
baseUrl={data.baseUrl}
/>
</main>
);

View File

@@ -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<Data, FreshContextState> = {
@@ -21,6 +23,9 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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, FreshContextState>) {
{data?.notice
? (
<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>
</section>
)
: null}
<form method='POST' class='mb-12'>
{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()))}
<section class='flex justify-center mt-8 mb-4'>
<button class='button' type='submit'>Login</button>
</section>
@@ -173,14 +188,14 @@ export default function Login({ data }: PageProps<Data, FreshContextState>) {
</strong>.
</p>
{helpEmail !== ''
{data?.helpEmail !== ''
? (
<>
<h2 class='text-2xl mb-4 text-center'>Need help?</h2>
<p class='text-center mt-2 mb-6'>
If you're having any issues or have any questions,{' '}
<strong>
<a href={`mailto:${helpEmail}`}>please reach out</a>
<a href={`mailto:${data?.helpEmail}`}>please reach out</a>
</strong>.
</p>
</>

View File

@@ -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<Data, FreshContextState> = {
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` } });
}

View File

@@ -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<Data, FreshContextState> = {
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` } });
}

View File

@@ -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<Data, FreshContextState> = {
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` } });
}

View File

@@ -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<string, any>;
currency?: SupportedCurrencySymbol;
isExpensesAppEnabled: boolean;
helpEmail: string;
}
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` } });
}
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
notice,
formData: convertFormDataToObject(formData),
currency: user.extra.expenses_currency,
isExpensesAppEnabled,
helpEmail,
});
} catch (error) {
console.error(error);
@@ -188,6 +200,8 @@ export const handler: Handlers<Data, FreshContextState> = {
error: { title: errorTitle, message: errorMessage },
formData: convertFormDataToObject(formData),
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>) {
return (
<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>
);
}

View File

@@ -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<Data, FreshContextState> = {
@@ -21,6 +22,8 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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<Data, FreshContextState> = {
});
} 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<Data, FreshContextState>) {
</strong>.
</p>
{helpEmail !== ''
{data?.helpEmail !== ''
? (
<>
<h2 class='text-2xl mb-4 text-center'>Need help?</h2>
<p class='text-center mt-2 mb-6'>
If you're having any issues or have any questions,{' '}
<strong>
<a href={`mailto:${helpEmail}`}>please reach out</a>
<a href={`mailto:${data?.helpEmail}`}>please reach out</a>
</strong>.
</p>
</>