Compare commits

...

4 commits
v4.0.3 ... main

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

* chore: simplify return statement

* chore: linted

* feat: verbose error on not enabled SSO sign up

* chore: update config key to match others

* chore: add key to config sample

* chore: apply suggestions from code review

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

---------

Co-authored-by: BrunoBernardino <me@brunobernardino.com>
2026-02-26 13:31:19 +00:00
11 changed files with 51 additions and 155 deletions

View file

@ -11,6 +11,7 @@ const config: PartialDeep<Config> = {
// allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies // allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies
// skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel // skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel
// enableSingleSignOn: false, // If true, single sign-on will be enabled // enableSingleSignOn: false, // If true, single sign-on will be enabled
// allowSignupsViaSingleSignOn: false, // If true, signups via single sign-on will be allowed, overriding allowSignups
// singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider // singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider
// singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider // singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider
// singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider // singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider
@ -19,9 +20,11 @@ const config: PartialDeep<Config> = {
// rootPath: 'data-files', // rootPath: 'data-files',
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) // allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
// allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files // allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files
// maxUploadSizeInMegabytes: 100, // The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints.
// }, // },
// core: { // core: {
// enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. // enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required.
// maxRequestSizeInMegabytes: 12, // The maximum request size in megabytes.
// }, // },
// visuals: { // visuals: {
// title: 'My own cloud', // title: 'My own cloud',

View file

@ -1,6 +1,6 @@
services: services:
website: website:
image: ghcr.io/bewcloud/bewcloud:v4.0.3 image: ghcr.io/bewcloud/bewcloud:v4.1.2
# NOTE: uncomment below (and comment above) only if you pulled the repo and want to build the image locally # NOTE: uncomment below (and comment above) only if you pulled the repo and want to build the image locally
# build: # build:
# context: . # context: .

View file

@ -16,6 +16,7 @@ export class AppConfig {
allowedCookieDomains: [], allowedCookieDomains: [],
skipCookieDomainSecurity: false, skipCookieDomainSecurity: false,
enableSingleSignOn: false, enableSingleSignOn: false,
allowSignupsViaSingleSignOn: false,
singleSignOnUrl: '', singleSignOnUrl: '',
singleSignOnEmailAttribute: 'email', singleSignOnEmailAttribute: 'email',
singleSignOnScopes: ['openid', 'email'], singleSignOnScopes: ['openid', 'email'],
@ -24,9 +25,11 @@ export class AppConfig {
rootPath: 'data-files', rootPath: 'data-files',
allowPublicSharing: false, allowPublicSharing: false,
allowDirectoryDownloads: false, allowDirectoryDownloads: false,
maxUploadSizeInMegabytes: 100,
}, },
core: { core: {
enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
maxRequestSizeInMegabytes: 12,
}, },
visuals: { visuals: {
title: '', title: '',
@ -117,11 +120,12 @@ export class AppConfig {
return this.config; return this.config;
} }
static async isSignupAllowed(): Promise<boolean> { static async isSignupAllowed({ viaSingleSignOn = false }: { viaSingleSignOn?: boolean } = {}): Promise<boolean> {
await this.loadConfig(); await this.loadConfig();
const areSignupsAllowed = this.config.auth.allowSignups; const areSignupsAllowed = viaSingleSignOn && !this.config.auth.allowSignups
? this.config.auth.allowSignupsViaSingleSignOn
: this.config.auth.allowSignups;
const areThereAdmins = await UserModel.isThereAnAdmin(); const areThereAdmins = await UserModel.isThereAnAdmin();
if (areSignupsAllowed || !areThereAdmins) { if (areSignupsAllowed || !areThereAdmins) {

View file

@ -169,7 +169,7 @@ export class OidcModel {
throw new Error(`Missing user/${emailAttribute}`); throw new Error(`Missing user/${emailAttribute}`);
} }
const isSignupAllowed = await AppConfig.isSignupAllowed(); const isSignupAllowed = await AppConfig.isSignupAllowed({ viaSingleSignOn: true });
const isThereAnAdmin = await UserModel.isThereAnAdmin(); const isThereAnAdmin = await UserModel.isThereAnAdmin();
// Confirm the user exists (or signup if allowed) // Confirm the user exists (or signup if allowed)
@ -181,6 +181,10 @@ export class OidcModel {
} }
if (!user) { if (!user) {
if (!config.auth.allowSignupsViaSingleSignOn) {
throw new Error('Sign up via SSO is not allowed!');
}
throw new Error('There was a problem signing up or logging in!'); throw new Error('There was a problem signing up or logging in!');
} }

View file

@ -17,15 +17,7 @@ export interface Page {
patch?: RequestHandler; patch?: RequestHandler;
delete?: RequestHandler; delete?: RequestHandler;
options?: RequestHandler; options?: RequestHandler;
copy?: RequestHandler; catchAll?: RequestHandler;
move?: RequestHandler;
mkcol?: RequestHandler;
mkcalendar?: RequestHandler;
lock?: RequestHandler;
unlock?: RequestHandler;
propfind?: RequestHandler;
proppatch?: RequestHandler;
report?: RequestHandler;
} }
type AccessMode = 'public' | 'user'; type AccessMode = 'public' | 'user';
@ -61,15 +53,7 @@ export default function page(
patch, patch,
delete: deleteAction, delete: deleteAction,
options, options,
copy, catchAll,
move,
mkcol,
mkcalendar,
lock,
unlock,
propfind,
proppatch,
report,
accessMode, accessMode,
}: Params, }: Params,
): Page { ): Page {
@ -80,14 +64,6 @@ export default function page(
patch: patch ? permissioned(patch, accessMode) : undefined, patch: patch ? permissioned(patch, accessMode) : undefined,
delete: deleteAction ? permissioned(deleteAction, accessMode) : undefined, delete: deleteAction ? permissioned(deleteAction, accessMode) : undefined,
options: options ? permissioned(options, accessMode) : undefined, options: options ? permissioned(options, accessMode) : undefined,
copy: copy ? permissioned(copy, accessMode) : undefined, catchAll: catchAll ? permissioned(catchAll, accessMode) : undefined,
move: move ? permissioned(move, accessMode) : undefined,
mkcol: mkcol ? permissioned(mkcol, accessMode) : undefined,
mkcalendar: mkcalendar ? permissioned(mkcalendar, accessMode) : undefined,
lock: lock ? permissioned(lock, accessMode) : undefined,
unlock: unlock ? permissioned(unlock, accessMode) : undefined,
propfind: propfind ? permissioned(propfind, accessMode) : undefined,
proppatch: proppatch ? permissioned(proppatch, accessMode) : undefined,
report: report ? permissioned(report, accessMode) : undefined,
}; };
} }

View file

@ -159,6 +159,8 @@ export interface Config {
skipCookieDomainSecurity: boolean; skipCookieDomainSecurity: boolean;
/** If true, single sign-on will be enabled */ /** If true, single sign-on will be enabled */
enableSingleSignOn: boolean; enableSingleSignOn: boolean;
/** If true, signups via single sign-on will be allowed, overriding allowSignups */
allowSignupsViaSingleSignOn: boolean;
/** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */ /** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */
singleSignOnUrl: string; singleSignOnUrl: string;
/** The attribute to prefer as email of the identity/single sign-on provider */ /** The attribute to prefer as email of the identity/single sign-on provider */
@ -173,10 +175,14 @@ export interface Config {
allowPublicSharing: boolean; allowPublicSharing: boolean;
/** If true, directories can be downloaded as zip files */ /** If true, directories can be downloaded as zip files */
allowDirectoryDownloads: boolean; allowDirectoryDownloads: boolean;
/** The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints. */
maxUploadSizeInMegabytes: number;
}; };
core: { core: {
/** The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. */ /** The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. */
enabledApps: OptionalApp[]; enabledApps: OptionalApp[];
/** The maximum request size in megabytes. */
maxRequestSizeInMegabytes: number;
}; };
visuals: { visuals: {
/** An override title of the application. Empty shows the default title. */ /** An override title of the application. Empty shows the default title. */

13
main.ts
View file

@ -1,9 +1,13 @@
import routes, { Route } from './routes.ts'; import routes, { Route } from './routes.ts';
import { startCrons } from './crons/index.ts'; import { startCrons } from './crons/index.ts';
import { Page } from './lib/page.ts'; import { Page } from './lib/page.ts';
import { AppConfig } from './lib/config.ts';
const MAX_REQUEST_SIZE_IN_MEGABYTES = 12; const config = await AppConfig.getConfig();
const MAX_REQUEST_SIZE_IN_MEGABYTES = config.core.maxRequestSizeInMegabytes;
const MAX_UPLOAD_SIZE_IN_MEGABYTES = config.files.maxUploadSizeInMegabytes;
const MAX_REQUEST_SIZE_IN_BYTES = MAX_REQUEST_SIZE_IN_MEGABYTES * 1024 * 1024; const MAX_REQUEST_SIZE_IN_BYTES = MAX_REQUEST_SIZE_IN_MEGABYTES * 1024 * 1024;
const MAX_UPLOAD_SIZE_IN_BYTES = MAX_UPLOAD_SIZE_IN_MEGABYTES * 1024 * 1024;
function applyCorsHeadersToResponse(origin: string, response: Response) { function applyCorsHeadersToResponse(origin: string, response: Response) {
const headers = response.headers; const headers = response.headers;
@ -53,12 +57,15 @@ function handleLogging(request: Request, response: Response) {
async function handler(request: Request) { async function handler(request: Request) {
const contentLength = request.headers.get('content-length'); const contentLength = request.headers.get('content-length');
const path = new URL(request.url).pathname;
if (contentLength && parseInt(contentLength, 10) > MAX_REQUEST_SIZE_IN_BYTES) { const isUploadRequest = path.startsWith('/api/files/upload') || path.startsWith('/dav');
const maxSizeInBytes = isUploadRequest ? MAX_UPLOAD_SIZE_IN_BYTES : MAX_REQUEST_SIZE_IN_BYTES;
if (contentLength && parseInt(contentLength, 10) > maxSizeInBytes) {
return new Response('Payload too large', { status: 413 }); return new Response('Payload too large', { status: 413 });
} }
const path = new URL(request.url).pathname;
const origin = request.headers.get('Origin') || '*'; const origin = request.headers.get('Origin') || '*';
// CORS headers for non-DAV routes // CORS headers for non-DAV routes

View file

@ -75,14 +75,6 @@ export default page({
put: get, put: get,
delete: get, delete: get,
options: get, options: get,
copy: get, catchAll: get,
move: get,
mkcol: get,
mkcalendar: get,
lock: get,
unlock: get,
propfind: get,
proppatch: get,
report: get,
accessMode: 'public', accessMode: 'public',
}); });

View file

@ -75,13 +75,6 @@ export default page({
put: get, put: get,
delete: get, delete: get,
options: get, options: get,
copy: get, catchAll: get,
move: get,
mkcol: get,
lock: get,
unlock: get,
propfind: get,
proppatch: get,
report: get,
accessMode: 'public', accessMode: 'public',
}); });

View file

@ -142,6 +142,8 @@ async function handler({ request, user, match }: RequestHandlerParams): Promise<
console.error(error); console.error(error);
} }
} }
return new Response('Not Found', { status: 404 });
} }
if (request.method === 'MKCOL') { if (request.method === 'MKCOL') {
@ -257,12 +259,6 @@ export default page({
delete: handler, delete: handler,
put: handler, put: handler,
options: handler, options: handler,
copy: handler, catchAll: handler,
move: handler,
mkcol: handler,
lock: handler,
unlock: handler,
propfind: handler,
report: handler,
accessMode: 'public', accessMode: 'public',
}); });

107
routes.ts
View file

@ -33,14 +33,7 @@ function createPageRouteHandler(id: string, pathname: string) {
patch, patch,
delete: deleteAction, delete: deleteAction,
options, options,
copy, catchAll,
move,
mkcol,
lock,
unlock,
propfind,
proppatch,
report,
} = page; } = page;
const { user, session, tokenData } = (await getDataFromRequest(request)) || {}; const { user, session, tokenData } = (await getDataFromRequest(request)) || {};
@ -148,95 +141,17 @@ function createPageRouteHandler(id: string, pathname: string) {
}); });
} }
break; break;
case 'COPY':
if (copy) {
return await copy({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'MOVE':
if (move) {
return await move({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'MKCOL':
if (mkcol) {
return await mkcol({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'LOCK':
if (lock) {
return await lock({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'UNLOCK':
if (unlock) {
return await unlock({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'PROPFIND':
if (propfind) {
return await propfind({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'PROPPATCH':
if (proppatch) {
return await proppatch({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
case 'REPORT':
if (report) {
return await report({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
break;
default: default:
if (catchAll) {
return await catchAll({
request,
match,
user,
session: { userSession: session, tokenData },
isRunningLocally,
});
}
return new Response('Not Implemented', { status: 501 }); return new Response('Not Implemented', { status: 501 });
} }