mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
feat(remix): support serving app under a sub-path via NEXT_PUBLIC_BASE_PATH (#2824)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { ClientResponse, InferRequestType } from 'hono/client';
|
||||
import { hc } from 'hono/client';
|
||||
@@ -36,8 +36,6 @@ type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$
|
||||
export class AuthClient {
|
||||
public client: AuthClientType;
|
||||
|
||||
private signOutredirectPath: string = '/signin';
|
||||
|
||||
constructor(options: { baseUrl: string }) {
|
||||
this.client = hc<AuthAppType>(options.baseUrl);
|
||||
}
|
||||
@@ -45,7 +43,7 @@ export class AuthClient {
|
||||
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
|
||||
await this.client.signout.$post();
|
||||
|
||||
window.location.href = redirectPath ?? this.signOutredirectPath;
|
||||
window.location.href = redirectPath ?? formatPath('/signin');
|
||||
}
|
||||
|
||||
public async signOutAllSessions() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
@@ -121,7 +121,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Check if signups are disabled for this provider.
|
||||
if (!isSignupEnabledForProvider(clientOptions.id as 'google' | 'microsoft' | 'oidc')) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -130,7 +130,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
// Check domain restriction for new SSO users.
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisabled);
|
||||
|
||||
@@ -141,7 +141,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
const errorUrl = new URL(formatPath('/signin'), NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
@@ -213,15 +213,18 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
// The sub-path aware root, e.g. "/" or "/ESign/".
|
||||
const defaultRedirectPath = formatPath('/');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
redirectPath = defaultRedirectPath;
|
||||
}
|
||||
|
||||
if (!isValidReturnTo(redirectPath)) {
|
||||
redirectPath = '/';
|
||||
redirectPath = defaultRedirectPath;
|
||||
}
|
||||
|
||||
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||
redirectPath = normalizeReturnTo(redirectPath) || defaultRedirectPath;
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(token_endpoint, code, storedCodeVerifier);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
@@ -56,7 +57,7 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(`/o/${orgUrl}`, 302);
|
||||
return c.redirect(formatPath(`/o/${orgUrl}`), 302);
|
||||
}
|
||||
|
||||
let userToLink = await prisma.user.findFirst({
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
/**
|
||||
* Derive the default redirect target ("/" at the root, "/ESign/" when served under a sub-path).
|
||||
*/
|
||||
const getDefaultRedirect = () => {
|
||||
try {
|
||||
const pathname = new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||
return `${pathname}/`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
const getWebAppOrigin = () => {
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
|
||||
} catch {
|
||||
return NEXT_PUBLIC_WEBAPP_URL();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an optional redirect path.
|
||||
*/
|
||||
@@ -10,19 +30,20 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
|
||||
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
if (url.origin !== getWebAppOrigin()) {
|
||||
window.location.href = getDefaultRedirect();
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleSignInRedirect = (redirectUrl: string = '/') => {
|
||||
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
|
||||
export const handleSignInRedirect = (redirectUrl?: string) => {
|
||||
const target = redirectUrl ?? getDefaultRedirect();
|
||||
const url = new URL(target, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
window.location.href = '/';
|
||||
if (url.origin !== getWebAppOrigin()) {
|
||||
window.location.href = getDefaultRedirect();
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
window.location.href = target;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,7 +12,10 @@ export type GetLimitsOptions = {
|
||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
|
||||
// Note: the path must be appended rather than passed as the `new URL()` path
|
||||
// argument, since a leading-slash path replaces the sub-path that
|
||||
// NEXT_PUBLIC_WEBAPP_URL may carry (e.g. https://host/ESign).
|
||||
const url = new URL(`${NEXT_PUBLIC_WEBAPP_URL()}/api/limits`);
|
||||
|
||||
if (teamId) {
|
||||
requestHeaders['team-id'] = teamId.toString();
|
||||
|
||||
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
|
||||
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
|
||||
* Returns an empty string when served at root.
|
||||
*
|
||||
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
|
||||
* into the Vite/React Router build). Falls back to the pathname of
|
||||
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
|
||||
* variable is unset.
|
||||
*
|
||||
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
|
||||
* for cases where the raw prefix itself is needed, such as path comparisons.
|
||||
*/
|
||||
export const getBasePath = (): string => {
|
||||
const explicit = env('NEXT_PUBLIC_BASE_PATH');
|
||||
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefix a root-relative path with the app's base path.
|
||||
*
|
||||
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
|
||||
* `/api/trpc` otherwise.
|
||||
*/
|
||||
export const formatPath = (path: string): string => {
|
||||
return `${getBasePath()}${path}`;
|
||||
};
|
||||
|
||||
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
|
||||
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
|
||||
import { env } from '../utils/env';
|
||||
|
||||
export const getBaseUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return '';
|
||||
return getBasePath();
|
||||
}
|
||||
|
||||
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { DocumentDataType } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { formatPath } from '../../constants/app';
|
||||
|
||||
export type GetFileOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
@@ -36,7 +38,7 @@ const getFileFromBytes64 = (data: string) => {
|
||||
};
|
||||
|
||||
const getFileFromS3 = async (key: string) => {
|
||||
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
|
||||
const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||
|
||||
import { formatPath } from '../../constants/app';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
|
||||
type File = {
|
||||
@@ -38,7 +39,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
||||
|
||||
formData.append('file', properFile);
|
||||
|
||||
const response = await fetch('/api/files/upload-pdf', {
|
||||
const response = await fetch(formatPath('/api/files/upload-pdf'), {
|
||||
method: 'POST',
|
||||
headers: buildUploadAuthHeaders(options),
|
||||
body: formData,
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
/**
|
||||
* The origin of the web app, ignoring any sub-path NEXT_PUBLIC_WEBAPP_URL carries.
|
||||
*/
|
||||
const getWebAppOrigin = () => {
|
||||
try {
|
||||
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
|
||||
} catch {
|
||||
return NEXT_PUBLIC_WEBAPP_URL();
|
||||
}
|
||||
};
|
||||
|
||||
export const isValidReturnTo = (returnTo?: string) => {
|
||||
if (!returnTo) {
|
||||
@@ -10,7 +21,10 @@ export const isValidReturnTo = (returnTo?: string) => {
|
||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||
// Compare against the origin, not the raw env value: when the app is served
|
||||
// under a sub-path NEXT_PUBLIC_WEBAPP_URL is e.g. "https://host/ESign", which
|
||||
// never equals a URL's origin ("https://host").
|
||||
if (returnToUrl.origin !== getWebAppOrigin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -30,7 +44,17 @@ export const normalizeReturnTo = (returnTo?: string) => {
|
||||
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||
const basePath = getBasePath();
|
||||
|
||||
let pathname = returnToUrl.pathname;
|
||||
|
||||
// A root-relative returnTo ("/inbox") resolves to a pathname without the
|
||||
// sub-path, so re-apply it when it is missing.
|
||||
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`)) {
|
||||
pathname = `${basePath}${pathname}`;
|
||||
}
|
||||
|
||||
return `${pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatPath } from '@documenso/lib/constants/app';
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -23,7 +24,7 @@ export const LanguageSwitcherDialog = ({ open, setOpen }: LanguageSwitcherDialog
|
||||
|
||||
formData.append('lang', lang);
|
||||
|
||||
await fetch('/api/locale', {
|
||||
await fetch(formatPath('/api/locale'), {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user