LibreChat/api/server/routes/config.js
Danny Avila e807c63d5d
🔐 fix: Gate Shared Startup Config By Link Access (#13897)
* fix: gate shared startup config by link access

* fix: satisfy shared config CI checks

* fix: align shared config client types

* fix: reject expired shared link access
2026-06-23 08:28:37 -04:00

317 lines
11 KiB
JavaScript

const express = require('express');
const {
isEnabled,
getBalanceConfig,
getCloudFrontConfig,
resolveBuildInfo,
resolveTitleTiming,
sanitizeModelSpecs,
isFileSnapshotEnabled,
} = require('@librechat/api');
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getRumConfig } = require('~/server/services/Config/rum');
const { getAppConfig } = require('~/server/services/Config/app');
const router = express.Router();
const emailLoginEnabled =
process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
const sharedLinksEnabled =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
const publicSharedLinksEnabled =
sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
/**
* Resolve build metadata eagerly at module load so the first `/api/config`
* request does not pay the cost of `execFileSync('git', ...)` on the hot path.
* The resolver caches its result after the first call.
*/
resolveBuildInfo();
function isBirthday() {
const today = new Date();
return today.getMonth() === 1 && today.getDate() === 11;
}
/**
* Pre-login fields rendered by the unauthenticated login, registration, password-reset,
* and email-verification pages. Any field added here is readable by anonymous callers
* of `GET /api/config`, so keep this set strictly to what those pages need.
*
* See client consumers under `client/src/components/Auth/` and `client/src/routes/Layouts/Startup.tsx`.
*/
function buildPreLoginPayload() {
const isOpenIdEnabled =
!!process.env.OPENID_CLIENT_ID &&
(isEnabled(process.env.OPENID_USE_PKCE) || !!process.env.OPENID_CLIENT_SECRET?.trim()) &&
!!process.env.OPENID_ISSUER &&
!!process.env.OPENID_SESSION_SECRET;
const isSamlEnabled =
!!process.env.SAML_ENTRY_POINT &&
!!process.env.SAML_ISSUER &&
!!process.env.SAML_CERT &&
!!process.env.SAML_SESSION_SECRET;
const ldap = getLdapConfig();
/** @type {Partial<TStartupConfig>} */
const payload = {
appTitle: process.env.APP_TITLE || 'LibreChat',
discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET,
facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
appleLoginEnabled:
!!process.env.APPLE_CLIENT_ID &&
!!process.env.APPLE_TEAM_ID &&
!!process.env.APPLE_KEY_ID &&
!!process.env.APPLE_PRIVATE_KEY_PATH,
openidLoginEnabled: isOpenIdEnabled,
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
openidImageUrl: process.env.OPENID_IMAGE_URL,
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled,
samlLabel: process.env.SAML_BUTTON_LABEL,
samlImageUrl: process.env.SAML_IMAGE_URL,
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
emailLoginEnabled,
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
emailEnabled:
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
!!process.env.EMAIL_USERNAME &&
!!process.env.EMAIL_PASSWORD &&
!!process.env.EMAIL_FROM,
passwordResetEnabled,
};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
if (minPasswordLength && !isNaN(minPasswordLength)) {
payload.minPasswordLength = minPasswordLength;
}
if (ldap) {
payload.ldap = ldap;
}
return payload;
}
/**
* Fields shared by authenticated chat and share-view config. Anonymous share
* views receive these through `/api/share/:shareId/config` after share access
* checks, not through the generic startup config endpoint.
*/
function buildPublicSharePayload() {
/** @type {Partial<TStartupConfig>} */
const payload = {
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
};
if (typeof process.env.CUSTOM_FOOTER === 'string') {
payload.customFooter = process.env.CUSTOM_FOOTER;
}
return payload;
}
/**
* Post-login fields appended only when `req.user` is present. These describe the
* authenticated UX (account-settings links, share-link feature flags, birthday icon,
* openid token-reuse marker) and are not needed on the pre-login screens, so they
* are not exposed to unauthenticated callers.
*/
function buildPostLoginPayload() {
/** @type {Partial<TStartupConfig>} */
const payload = {
showBirthdayIcon:
isBirthday() ||
isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
sharedLinksEnabled,
publicSharedLinksEnabled,
openidReuseTokens,
/** Read inline (not module-level) for per-request evaluation and test isolation */
allowAccountDeletion:
process.env.ALLOW_ACCOUNT_DELETION === undefined ||
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
};
return payload;
}
function buildBuildInfoPayload(interfaceConfig) {
if (interfaceConfig?.buildInfo === false) {
return undefined;
}
const info = resolveBuildInfo();
if (!info.commit && !info.branch && !info.buildDate) {
return undefined;
}
return {
commit: info.commit,
commitShort: info.commitShort,
branch: info.branch,
buildDate: info.buildDate,
};
}
function buildWebSearchConfig(appConfig) {
const ws = appConfig?.webSearch;
if (!ws) {
return undefined;
}
const { searchProvider, scraperProvider, rerankerType } = ws;
if (!searchProvider && !scraperProvider && !rerankerType) {
return undefined;
}
return {
...(searchProvider && { searchProvider }),
...(scraperProvider && { scraperProvider }),
...(rerankerType && { rerankerType }),
};
}
function buildCloudFrontStartupConfig() {
const config = getCloudFrontConfig();
if (
config?.imageSigning !== 'cookies' ||
!config.domain ||
!config.cookieDomain ||
!config.privateKey ||
!config.keyPairId
) {
return undefined;
}
return {
cookieRefresh: {
endpoint: '/api/auth/cloudfront/refresh',
domain: config.domain,
},
};
}
router.get('/', async function (req, res) {
try {
const preLoginPayload = buildPreLoginPayload();
const publicSharePayload = buildPublicSharePayload();
const rum = getRumConfig();
if (!req.user) {
const tenantId = getTenantId();
const baseConfig = await getAppConfig(tenantId ? { tenantId } : { baseOnly: true });
/** @type {Partial<TStartupConfig>} */
const payload = {
...preLoginPayload,
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
turnstile: baseConfig?.turnstileConfig,
...(rum ? { rum } : {}),
};
const interfaceConfig = baseConfig?.interfaceConfig;
const buildInfoDisabled = interfaceConfig?.buildInfo === false;
if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) {
payload.interface = {};
if (interfaceConfig.privacyPolicy) {
payload.interface.privacyPolicy = interfaceConfig.privacyPolicy;
}
if (interfaceConfig.termsOfService) {
payload.interface.termsOfService = interfaceConfig.termsOfService;
}
if (buildInfoDisabled) {
payload.interface.buildInfo = false;
}
}
const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig);
if (unauthBuildInfo) {
payload.buildInfo = unauthBuildInfo;
}
return res.status(200).send(payload);
}
const appConfig = await getAppConfig({
role: req.user.role,
userId: req.user.id,
tenantId: req.user.tenantId || getTenantId(),
});
const balanceConfig = getBalanceConfig(appConfig);
const cloudFront = buildCloudFrontStartupConfig();
/** @type {TStartupConfig} */
const payload = {
...preLoginPayload,
...publicSharePayload,
...buildPostLoginPayload(),
sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig),
socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
interface: appConfig?.interfaceConfig,
titleGenerationTiming: resolveTitleTiming({
appConfig,
endpoint: EModelEndpoint.agents,
}),
turnstile: appConfig?.turnstileConfig,
modelSpecs: sanitizeModelSpecs(appConfig?.modelSpecs),
balance: balanceConfig,
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
sharePointFilePickerEnabled,
sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
};
const webSearch = buildWebSearchConfig(appConfig);
if (webSearch) {
payload.webSearch = webSearch;
}
const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig);
if (buildInfo) {
payload.buildInfo = buildInfo;
}
if (!payload.allowAccountDeletion) {
try {
const userId = req.user.id ?? req.user._id?.toString();
if (userId) {
const canDelete = await hasCapability(
{ id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId },
SystemCapabilities.ACCESS_ADMIN,
);
if (canDelete) {
payload.allowAccountDeletion = true;
}
}
} catch (err) {
logger.warn(`[config] ACCESS_ADMIN capability check failed: ${err.message}`);
}
}
return res.status(200).send(payload);
} catch (err) {
logger.error('Error in startup config', err);
return res.status(500).send({ error: err.message });
}
});
module.exports = router;