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

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