fix: various fixes to things + simply app proxy sentry setup
This commit is contained in:
parent
ff1d15f7aa
commit
528e4e0d7f
@ -170,22 +170,6 @@ function stripApiSuffix(url) {
|
|||||||
return url.endsWith('/api') ? url.slice(0, -4) : url;
|
return url.endsWith('/api') ? url.slice(0, -4) : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSentryDsn(dsn) {
|
|
||||||
if (!dsn) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = new URL(dsn);
|
|
||||||
const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
|
|
||||||
const segments = path ? path.split('/') : [];
|
|
||||||
const projectId = segments.length > 0 ? segments[segments.length - 1] : undefined;
|
|
||||||
const publicKey = parsed.username || undefined;
|
|
||||||
return {projectId, publicKey};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAppPublic(config) {
|
function resolveAppPublic(config) {
|
||||||
const appPublic = getValue(config, ['app_public'], {});
|
const appPublic = getValue(config, ['app_public'], {});
|
||||||
const domain = getValue(config, ['domain'], {});
|
const domain = getValue(config, ['domain'], {});
|
||||||
@ -193,25 +177,13 @@ function resolveAppPublic(config) {
|
|||||||
const endpoints = deriveEndpointsFromDomain(domain, overrides);
|
const endpoints = deriveEndpointsFromDomain(domain, overrides);
|
||||||
const defaultBootstrapEndpoint = endpoints.api;
|
const defaultBootstrapEndpoint = endpoints.api;
|
||||||
const defaultPublicEndpoint = stripApiSuffix(endpoints.api);
|
const defaultPublicEndpoint = stripApiSuffix(endpoints.api);
|
||||||
const sentryDsn =
|
const sentryDsn = asString(appPublic.sentry_dsn);
|
||||||
asString(appPublic.sentry_dsn) ?? asString(getValue(config, ['services', 'app_proxy', 'sentry_dsn']));
|
|
||||||
const sentryParsed = parseSentryDsn(sentryDsn);
|
|
||||||
return {
|
return {
|
||||||
apiVersion: asString(appPublic.api_version, '1'),
|
apiVersion: asString(appPublic.api_version, '1'),
|
||||||
bootstrapApiEndpoint: asString(appPublic.bootstrap_api_endpoint, defaultBootstrapEndpoint),
|
bootstrapApiEndpoint: asString(appPublic.bootstrap_api_endpoint, defaultBootstrapEndpoint),
|
||||||
bootstrapApiPublicEndpoint: asString(appPublic.bootstrap_api_public_endpoint, defaultPublicEndpoint),
|
bootstrapApiPublicEndpoint: asString(appPublic.bootstrap_api_public_endpoint, defaultPublicEndpoint),
|
||||||
relayDirectoryUrl: asString(appPublic.relay_directory_url),
|
relayDirectoryUrl: asString(appPublic.relay_directory_url),
|
||||||
sentryDsn,
|
sentryDsn,
|
||||||
sentryProxyPath: asString(
|
|
||||||
appPublic.sentry_proxy_path,
|
|
||||||
asString(getValue(config, ['services', 'app_proxy', 'sentry_proxy_path']), '/error-reporting-proxy'),
|
|
||||||
),
|
|
||||||
sentryReportHost: asString(
|
|
||||||
appPublic.sentry_report_host,
|
|
||||||
asString(getValue(config, ['services', 'app_proxy', 'sentry_report_host']), ''),
|
|
||||||
),
|
|
||||||
sentryProjectId: asString(appPublic.sentry_project_id, sentryParsed.projectId),
|
|
||||||
sentryPublicKey: asString(appPublic.sentry_public_key, sentryParsed.publicKey),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,9 +234,6 @@ export default () => {
|
|||||||
PUBLIC_BUILD_TIMESTAMP: buildMetadata.buildTimestamp,
|
PUBLIC_BUILD_TIMESTAMP: buildMetadata.buildTimestamp,
|
||||||
PUBLIC_RELEASE_CHANNEL: buildMetadata.releaseChannel,
|
PUBLIC_RELEASE_CHANNEL: buildMetadata.releaseChannel,
|
||||||
PUBLIC_SENTRY_DSN: appPublic.sentryDsn ?? null,
|
PUBLIC_SENTRY_DSN: appPublic.sentryDsn ?? null,
|
||||||
PUBLIC_SENTRY_PROJECT_ID: appPublic.sentryProjectId ?? null,
|
|
||||||
PUBLIC_SENTRY_PUBLIC_KEY: appPublic.sentryPublicKey ?? null,
|
|
||||||
PUBLIC_SENTRY_PROXY_PATH: appPublic.sentryProxyPath,
|
|
||||||
PUBLIC_API_VERSION: appPublic.apiVersion,
|
PUBLIC_API_VERSION: appPublic.apiVersion,
|
||||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: appPublic.bootstrapApiEndpoint,
|
PUBLIC_BOOTSTRAP_API_ENDPOINT: appPublic.bootstrapApiEndpoint,
|
||||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: appPublic.bootstrapApiPublicEndpoint ?? appPublic.bootstrapApiEndpoint,
|
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: appPublic.bootstrapApiPublicEndpoint ?? appPublic.bootstrapApiEndpoint,
|
||||||
@ -482,9 +451,6 @@ export default () => {
|
|||||||
'import.meta.env.PUBLIC_BUILD_TIMESTAMP': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_TIMESTAMP'),
|
'import.meta.env.PUBLIC_BUILD_TIMESTAMP': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_TIMESTAMP'),
|
||||||
'import.meta.env.PUBLIC_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
|
'import.meta.env.PUBLIC_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
|
||||||
'import.meta.env.PUBLIC_SENTRY_DSN': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_DSN'),
|
'import.meta.env.PUBLIC_SENTRY_DSN': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_DSN'),
|
||||||
'import.meta.env.PUBLIC_SENTRY_PROJECT_ID': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_PROJECT_ID'),
|
|
||||||
'import.meta.env.PUBLIC_SENTRY_PUBLIC_KEY': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_PUBLIC_KEY'),
|
|
||||||
'import.meta.env.PUBLIC_SENTRY_PROXY_PATH': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_PROXY_PATH'),
|
|
||||||
'import.meta.env.PUBLIC_API_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_API_VERSION'),
|
'import.meta.env.PUBLIC_API_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_API_VERSION'),
|
||||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
|
'import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
|
||||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(
|
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(
|
||||||
|
|||||||
@ -189,7 +189,13 @@ export const PreloadableUserPopout = React.forwardRef<
|
|||||||
<Popout
|
<Popout
|
||||||
ref={ref}
|
ref={ref}
|
||||||
render={({popoutKey}) => (
|
render={({popoutKey}) => (
|
||||||
<UserProfilePopout popoutKey={popoutKey} user={user} isWebhook={isWebhook} guildId={guildId} />
|
<UserProfilePopout
|
||||||
|
key={`${user.id}:${guildId ?? 'global'}:${isWebhook ? 'webhook' : 'user'}`}
|
||||||
|
popoutKey={popoutKey}
|
||||||
|
user={user}
|
||||||
|
isWebhook={isWebhook}
|
||||||
|
guildId={guildId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
tooltip={tooltip}
|
tooltip={tooltip}
|
||||||
|
|||||||
@ -129,6 +129,10 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
|||||||
);
|
);
|
||||||
const [profile, setProfile] = useState<ProfileRecord | null>(initialProfile);
|
const [profile, setProfile] = useState<ProfileRecord | null>(initialProfile);
|
||||||
const [isProfileLoading, setIsProfileLoading] = useState(() => !initialProfile);
|
const [isProfileLoading, setIsProfileLoading] = useState(() => !initialProfile);
|
||||||
|
const profileMatchesContext = profile?.userId === userId && (profile?.guildId ?? null) === (guildId ?? null);
|
||||||
|
const activeProfile = profileMatchesContext ? profile : initialProfile;
|
||||||
|
const isContextSwitching = Boolean(userId) && !activeProfile && !profileMatchesContext;
|
||||||
|
const shouldShowProfileLoading = (isProfileLoading && !activeProfile) || isContextSwitching;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfile(initialProfile);
|
setProfile(initialProfile);
|
||||||
@ -136,7 +140,7 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
|||||||
}, [initialProfile]);
|
}, [initialProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || profile) {
|
if (!userId || activeProfile) {
|
||||||
setIsProfileLoading(false);
|
setIsProfileLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -164,7 +168,7 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [userId, guildId, profile]);
|
}, [userId, guildId, activeProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!guildId || !userId) {
|
if (!guildId || !userId) {
|
||||||
@ -190,22 +194,24 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveProfile: ProfileRecord | null = profile ?? mockProfile ?? fallbackProfile;
|
const effectiveProfile: ProfileRecord | null = activeProfile ?? mockProfile ?? fallbackProfile;
|
||||||
const resolvedProfile: ProfileRecord = effectiveProfile ?? fallbackProfile!;
|
const resolvedProfile: ProfileRecord = effectiveProfile ?? fallbackProfile!;
|
||||||
const userNote = userId ? UserNoteStore.getUserNote(userId) : null;
|
const userNote = userId ? UserNoteStore.getUserNote(userId) : null;
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
store.close();
|
store.close();
|
||||||
};
|
};
|
||||||
|
const profileIdentityKey = `${displayUser.id}:${guildId ?? 'global'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProfileMobileSheetContent
|
<UserProfileMobileSheetContent
|
||||||
|
key={profileIdentityKey}
|
||||||
user={displayUser}
|
user={displayUser}
|
||||||
profile={resolvedProfile}
|
profile={resolvedProfile}
|
||||||
userNote={userNote}
|
userNote={userNote}
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
autoFocusNote={autoFocusNote}
|
autoFocusNote={autoFocusNote}
|
||||||
isLoading={isProfileLoading}
|
isLoading={shouldShowProfileLoading}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -112,32 +112,53 @@ export const UserProfilePopout: React.FC<UserProfilePopoutProps> = observer(
|
|||||||
[isWebhook, user.id, guildId, popoutKey],
|
[isWebhook, user.id, guildId, popoutKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchProfile = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (isWebhook) return;
|
let cancelled = false;
|
||||||
|
const cachedProfile = UserProfileStore.getProfile(user.id, guildId);
|
||||||
const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : false;
|
setProfile(cachedProfile ?? createMockProfile(user));
|
||||||
|
|
||||||
if (DeveloperOptionsStore.slowProfileLoad) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
}
|
|
||||||
|
|
||||||
setProfileLoadError(false);
|
setProfileLoadError(false);
|
||||||
|
|
||||||
try {
|
if (isWebhook) {
|
||||||
const fetchedProfile = await UserProfileActionCreators.fetch(user.id, isGuildMember ? guildId : undefined);
|
return () => {
|
||||||
setProfile(fetchedProfile);
|
cancelled = true;
|
||||||
setProfileLoadError(false);
|
};
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch profile for user popout', error);
|
|
||||||
const cachedProfile = UserProfileStore.getProfile(user.id, guildId);
|
|
||||||
setProfile(cachedProfile ?? createMockProfile(user));
|
|
||||||
setProfileLoadError(true);
|
|
||||||
}
|
}
|
||||||
}, [guildId, user.id, isWebhook]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchProfile = async () => {
|
||||||
|
const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : false;
|
||||||
|
|
||||||
|
if (DeveloperOptionsStore.slowProfileLoad) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedProfile = await UserProfileActionCreators.fetch(user.id, isGuildMember ? guildId : undefined);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProfile(fetchedProfile);
|
||||||
|
setProfileLoadError(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Failed to fetch profile for user popout', error);
|
||||||
|
const nextCachedProfile = UserProfileStore.getProfile(user.id, guildId);
|
||||||
|
setProfile(nextCachedProfile ?? createMockProfile(user));
|
||||||
|
setProfileLoadError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, [fetchProfile]);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [guildId, isWebhook, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profileLoadError && profile) {
|
if (profileLoadError && profile) {
|
||||||
|
|||||||
@ -248,7 +248,13 @@ const SpectatorRow = observer(function SpectatorRow({
|
|||||||
return (
|
return (
|
||||||
<Popout
|
<Popout
|
||||||
render={({popoutKey}) => (
|
render={({popoutKey}) => (
|
||||||
<UserProfilePopout popoutKey={popoutKey} user={user} isWebhook={false} guildId={guildId} />
|
<UserProfilePopout
|
||||||
|
key={`${user.id}:${guildId ?? 'global'}:user`}
|
||||||
|
popoutKey={popoutKey}
|
||||||
|
user={user}
|
||||||
|
isWebhook={false}
|
||||||
|
guildId={guildId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
position="left-start"
|
position="left-start"
|
||||||
onOpen={onPopoutOpen}
|
onOpen={onPopoutOpen}
|
||||||
|
|||||||
@ -157,7 +157,13 @@ function VoiceParticipantPopoutRow({entry, guildId, channelId}: VoiceParticipant
|
|||||||
return (
|
return (
|
||||||
<Popout
|
<Popout
|
||||||
render={({popoutKey}) => (
|
render={({popoutKey}) => (
|
||||||
<UserProfilePopout popoutKey={popoutKey} user={entry.user} isWebhook={false} guildId={guildId ?? undefined} />
|
<UserProfilePopout
|
||||||
|
key={`${entry.user.id}:${guildId ?? 'global'}:user`}
|
||||||
|
popoutKey={popoutKey}
|
||||||
|
user={entry.user}
|
||||||
|
isWebhook={false}
|
||||||
|
guildId={guildId ?? undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
position="left-start"
|
position="left-start"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -70,8 +70,6 @@ const logger = new Logger('index');
|
|||||||
|
|
||||||
preloadClientInfo();
|
preloadClientInfo();
|
||||||
|
|
||||||
const normalizePathSegment = (value: string): string => value.replace(/^\/+|\/+$/g, '');
|
|
||||||
|
|
||||||
async function resumePendingDesktopHandoffLogin(): Promise<void> {
|
async function resumePendingDesktopHandoffLogin(): Promise<void> {
|
||||||
const electronApi = getElectronAPI();
|
const electronApi = getElectronAPI();
|
||||||
if (!electronApi || typeof electronApi.consumeDesktopHandoffCode !== 'function') {
|
if (!electronApi || typeof electronApi.consumeDesktopHandoffCode !== 'function') {
|
||||||
@ -103,7 +101,7 @@ async function resumePendingDesktopHandoffLogin(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initSentry(): void {
|
function initSentry(): void {
|
||||||
const resolvedSentryDsn = buildRuntimeSentryDsn() || RuntimeConfigStore.sentryDsn;
|
const resolvedSentryDsn = RuntimeConfigStore.sentryDsn;
|
||||||
const normalizedBuildSha =
|
const normalizedBuildSha =
|
||||||
Config.PUBLIC_BUILD_SHA && Config.PUBLIC_BUILD_SHA !== 'dev' ? Config.PUBLIC_BUILD_SHA : undefined;
|
Config.PUBLIC_BUILD_SHA && Config.PUBLIC_BUILD_SHA !== 'dev' ? Config.PUBLIC_BUILD_SHA : undefined;
|
||||||
const buildNumberString =
|
const buildNumberString =
|
||||||
@ -167,24 +165,6 @@ function initSentry(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRuntimeSentryDsn(): string | null {
|
|
||||||
if (!RuntimeConfigStore.sentryProjectId || !RuntimeConfigStore.sentryPublicKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = window.location.origin;
|
|
||||||
if (!origin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyPath = normalizePathSegment(RuntimeConfigStore.sentryProxyPath ?? '/error-reporting-proxy');
|
|
||||||
const projectSegment = normalizePathSegment(RuntimeConfigStore.sentryProjectId);
|
|
||||||
|
|
||||||
const url = new URL(`/${proxyPath}/${projectSegment}`, origin);
|
|
||||||
url.username = RuntimeConfigStore.sentryPublicKey;
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
await initI18n();
|
await initI18n();
|
||||||
|
|
||||||
|
|||||||
@ -310,10 +310,6 @@ class AccountStorage {
|
|||||||
sso: instance.sso,
|
sso: instance.sso,
|
||||||
publicPushVapidKey: instance.publicPushVapidKey,
|
publicPushVapidKey: instance.publicPushVapidKey,
|
||||||
sentryDsn: instance.sentryDsn ?? '',
|
sentryDsn: instance.sentryDsn ?? '',
|
||||||
sentryProxyPath: instance.sentryProxyPath ?? '',
|
|
||||||
sentryReportHost: instance.sentryReportHost ?? '',
|
|
||||||
sentryProjectId: instance.sentryProjectId ?? '',
|
|
||||||
sentryPublicKey: instance.sentryPublicKey ?? '',
|
|
||||||
limits:
|
limits:
|
||||||
instance.limits !== undefined && instance.limits !== null
|
instance.limits !== undefined && instance.limits !== null
|
||||||
? JSON.parse(JSON.stringify(instance.limits))
|
? JSON.parse(JSON.stringify(instance.limits))
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
MarkdownContext,
|
||||||
|
type MarkdownRenderOptions,
|
||||||
|
type RendererProps,
|
||||||
|
} from '@app/lib/markdown/renderers/RendererTypes';
|
||||||
|
import {TimestampRenderer} from '@app/lib/markdown/renderers/TimestampRenderer';
|
||||||
|
import {NodeType, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||||
|
import type {TimestampNode} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||||
|
import {setupI18n} from '@lingui/core';
|
||||||
|
import React from 'react';
|
||||||
|
import {renderToStaticMarkup} from 'react-dom/server';
|
||||||
|
import {describe, expect, test} from 'vitest';
|
||||||
|
|
||||||
|
const i18n = setupI18n({locale: 'en-US', messages: {'en-US': {}}});
|
||||||
|
|
||||||
|
function createRendererProps(node: TimestampNode): RendererProps<TimestampNode> {
|
||||||
|
const options: MarkdownRenderOptions = {
|
||||||
|
context: MarkdownContext.STANDARD_WITHOUT_JUMBO,
|
||||||
|
shouldJumboEmojis: false,
|
||||||
|
i18n,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
id: 'test-timestamp',
|
||||||
|
renderChildren: () => null,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TimestampRenderer', () => {
|
||||||
|
test('renders a non-throwing fallback for invalid timestamps', () => {
|
||||||
|
const props = createRendererProps({
|
||||||
|
type: NodeType.Timestamp,
|
||||||
|
timestamp: Number.POSITIVE_INFINITY,
|
||||||
|
style: TimestampStyle.ShortDateTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderFn = () => renderToStaticMarkup(React.createElement(TimestampRenderer, props));
|
||||||
|
|
||||||
|
expect(renderFn).not.toThrow();
|
||||||
|
const markup = renderFn();
|
||||||
|
expect(markup).toContain('Infinity');
|
||||||
|
expect(markup).not.toContain('<time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a semantic time element for valid timestamps', () => {
|
||||||
|
const props = createRendererProps({
|
||||||
|
type: NodeType.Timestamp,
|
||||||
|
timestamp: 1618953630,
|
||||||
|
style: TimestampStyle.ShortDateTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(React.createElement(TimestampRenderer, props));
|
||||||
|
|
||||||
|
expect(markup).toContain('<time');
|
||||||
|
expect(markup).toContain('dateTime="2021-04-20T21:20:30.000Z"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -20,6 +20,7 @@
|
|||||||
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
||||||
import type {RendererProps} from '@app/lib/markdown/renderers/RendererTypes';
|
import type {RendererProps} from '@app/lib/markdown/renderers/RendererTypes';
|
||||||
import {formatTimestamp} from '@app/lib/markdown/utils/DateFormatter';
|
import {formatTimestamp} from '@app/lib/markdown/utils/DateFormatter';
|
||||||
|
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
|
||||||
import WindowStore from '@app/stores/WindowStore';
|
import WindowStore from '@app/stores/WindowStore';
|
||||||
import markupStyles from '@app/styles/Markup.module.css';
|
import markupStyles from '@app/styles/Markup.module.css';
|
||||||
import timestampRendererStyles from '@app/styles/TimestampRenderer.module.css';
|
import timestampRendererStyles from '@app/styles/TimestampRenderer.module.css';
|
||||||
@ -32,8 +33,7 @@ import {ClockIcon} from '@phosphor-icons/react';
|
|||||||
import {clsx} from 'clsx';
|
import {clsx} from 'clsx';
|
||||||
import {DateTime} from 'luxon';
|
import {DateTime} from 'luxon';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import type {ReactElement} from 'react';
|
import React, {type ReactElement, useEffect, useState} from 'react';
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
|
|
||||||
export const TimestampRenderer = observer(function TimestampRenderer({
|
export const TimestampRenderer = observer(function TimestampRenderer({
|
||||||
node,
|
node,
|
||||||
@ -43,25 +43,26 @@ export const TimestampRenderer = observer(function TimestampRenderer({
|
|||||||
const {timestamp, style} = node;
|
const {timestamp, style} = node;
|
||||||
const i18n = options.i18n;
|
const i18n = options.i18n;
|
||||||
|
|
||||||
const totalMillis = timestamp * 1000;
|
const date = getDateFromUnixTimestampSeconds(timestamp);
|
||||||
const date = new Date(totalMillis);
|
const isValidTimestamp = date !== null;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const isPast = date < now;
|
const isPast = date !== null && date < now;
|
||||||
const isFuture = date > now;
|
const isFuture = date !== null && date > now;
|
||||||
const isTodayDate = isSameDay(date);
|
const isTodayDate = date !== null && isSameDay(date);
|
||||||
|
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
const fullDateTime = getFormattedDateTimeWithSeconds(date, locale);
|
const fullDateTime = date !== null ? getFormattedDateTimeWithSeconds(date, locale) : null;
|
||||||
|
|
||||||
const isRelativeStyle = style === TimestampStyle.RelativeTime;
|
const isRelativeStyle = style === TimestampStyle.RelativeTime;
|
||||||
const isWindowFocused = WindowStore.focused;
|
const isWindowFocused = WindowStore.focused;
|
||||||
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() => formatTimestamp(timestamp, style, i18n));
|
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() =>
|
||||||
const luxonDate = DateTime.fromMillis(totalMillis);
|
isValidTimestamp ? formatTimestamp(timestamp, style, i18n) : '',
|
||||||
const relativeTime = luxonDate.toRelative();
|
);
|
||||||
|
const relativeTime = date !== null ? DateTime.fromJSDate(date).toRelative() : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRelativeStyle || !isWindowFocused) {
|
if (!isValidTimestamp || !isRelativeStyle || !isWindowFocused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +76,11 @@ export const TimestampRenderer = observer(function TimestampRenderer({
|
|||||||
refreshDisplay();
|
refreshDisplay();
|
||||||
const intervalId = setInterval(refreshDisplay, 1000);
|
const intervalId = setInterval(refreshDisplay, 1000);
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
|
}, [isValidTimestamp, isRelativeStyle, isWindowFocused, style, timestamp, i18n]);
|
||||||
|
|
||||||
|
if (date === null || fullDateTime === null) {
|
||||||
|
return React.createElement('span', {className: markupStyles.timestamp}, String(timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
const tooltipContent = (
|
const tooltipContent = (
|
||||||
<div className={timestampRendererStyles.tooltipContainer}>
|
<div className={timestampRendererStyles.tooltipContainer}>
|
||||||
|
|||||||
53
fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx
Normal file
53
fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {formatTimestamp} from '@app/lib/markdown/utils/DateFormatter';
|
||||||
|
import {TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||||
|
import {setupI18n} from '@lingui/core';
|
||||||
|
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
|
const i18n = setupI18n({locale: 'en-US', messages: {'en-US': {}}});
|
||||||
|
|
||||||
|
describe('formatTimestamp', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the raw numeric value for non-finite timestamps', () => {
|
||||||
|
const output = formatTimestamp(Number.POSITIVE_INFINITY, TimestampStyle.ShortDateTime, i18n);
|
||||||
|
|
||||||
|
expect(output).toBe('Infinity');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the raw numeric value for out-of-range timestamps', () => {
|
||||||
|
const output = formatTimestamp(8640000000001, TimestampStyle.ShortDateTime, i18n);
|
||||||
|
|
||||||
|
expect(output).toBe('8640000000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still formats valid relative timestamps', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-02-18T12:00:00.000Z'));
|
||||||
|
|
||||||
|
const oneMinuteAgoTimestamp = Math.floor(new Date('2026-02-18T11:59:00.000Z').getTime() / 1000);
|
||||||
|
const output = formatTimestamp(oneMinuteAgoTimestamp, TimestampStyle.RelativeTime, i18n);
|
||||||
|
|
||||||
|
expect(output.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -17,6 +17,7 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
|
||||||
import {shouldUse12HourFormat} from '@app/utils/DateUtils';
|
import {shouldUse12HourFormat} from '@app/utils/DateUtils';
|
||||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||||
import {
|
import {
|
||||||
@ -33,9 +34,8 @@ import {TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
|||||||
import type {I18n} from '@lingui/core';
|
import type {I18n} from '@lingui/core';
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number, i18n: I18n): string {
|
function formatRelativeTime(date: Date, i18n: I18n): string {
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
const date = new Date(timestamp * 1000);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (isSameDay(date, now)) {
|
if (isSameDay(date, now)) {
|
||||||
@ -142,9 +142,14 @@ function formatRelativeTime(timestamp: number, i18n: I18n): string {
|
|||||||
export function formatTimestamp(timestamp: number, style: TimestampStyle, i18n: I18n): string {
|
export function formatTimestamp(timestamp: number, style: TimestampStyle, i18n: I18n): string {
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
const hour12 = shouldUse12HourFormat(locale);
|
const hour12 = shouldUse12HourFormat(locale);
|
||||||
|
const date = getDateFromUnixTimestampSeconds(timestamp);
|
||||||
|
|
||||||
|
if (date == null) {
|
||||||
|
return String(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
if (style === TimestampStyle.RelativeTime) {
|
if (style === TimestampStyle.RelativeTime) {
|
||||||
return formatRelativeTime(timestamp, i18n);
|
return formatRelativeTime(date, i18n);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatTimestampWithStyle(timestamp, style, locale, hour12);
|
return formatTimestampWithStyle(timestamp, style, locale, hour12);
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
|
||||||
|
import {describe, expect, test} from 'vitest';
|
||||||
|
|
||||||
|
describe('getDateFromUnixTimestampSeconds', () => {
|
||||||
|
test('returns a valid date for normal unix timestamps', () => {
|
||||||
|
const result = getDateFromUnixTimestampSeconds(1618953630);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Date);
|
||||||
|
expect(result?.toISOString()).toBe('2021-04-20T21:20:30.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for infinity', () => {
|
||||||
|
expect(getDateFromUnixTimestampSeconds(Number.POSITIVE_INFINITY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for NaN', () => {
|
||||||
|
expect(getDateFromUnixTimestampSeconds(Number.NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when timestamp is beyond js date range', () => {
|
||||||
|
expect(getDateFromUnixTimestampSeconds(8640000000001)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx
Normal file
38
fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Fluxer Contributors
|
||||||
|
*
|
||||||
|
* This file is part of Fluxer.
|
||||||
|
*
|
||||||
|
* Fluxer is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Fluxer is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MILLISECONDS_PER_SECOND = 1000;
|
||||||
|
|
||||||
|
export function getDateFromUnixTimestampSeconds(timestamp: number): Date | null {
|
||||||
|
if (!Number.isFinite(timestamp)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampMillis = timestamp * MILLISECONDS_PER_SECOND;
|
||||||
|
if (!Number.isFinite(timestampMillis)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(timestampMillis);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
@ -243,10 +243,6 @@ class InstanceConfigStore {
|
|||||||
push: {public_vapid_key: RuntimeConfigStore.publicPushVapidKey},
|
push: {public_vapid_key: RuntimeConfigStore.publicPushVapidKey},
|
||||||
appPublic: {
|
appPublic: {
|
||||||
sentry_dsn: RuntimeConfigStore.sentryDsn,
|
sentry_dsn: RuntimeConfigStore.sentryDsn,
|
||||||
sentry_proxy_path: RuntimeConfigStore.sentryProxyPath,
|
|
||||||
sentry_report_host: RuntimeConfigStore.sentryReportHost,
|
|
||||||
sentry_project_id: RuntimeConfigStore.sentryProjectId,
|
|
||||||
sentry_public_key: RuntimeConfigStore.sentryPublicKey,
|
|
||||||
},
|
},
|
||||||
federation: null,
|
federation: null,
|
||||||
publicKey: null,
|
publicKey: null,
|
||||||
|
|||||||
@ -69,10 +69,6 @@ export interface InstancePush {
|
|||||||
|
|
||||||
export interface InstanceAppPublic {
|
export interface InstanceAppPublic {
|
||||||
sentry_dsn: string;
|
sentry_dsn: string;
|
||||||
sentry_proxy_path: string;
|
|
||||||
sentry_report_host: string;
|
|
||||||
sentry_project_id: string;
|
|
||||||
sentry_public_key: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GifProvider = 'klipy' | 'tenor';
|
export type GifProvider = 'klipy' | 'tenor';
|
||||||
@ -110,10 +106,6 @@ export interface RuntimeConfigSnapshot {
|
|||||||
publicPushVapidKey: string | null;
|
publicPushVapidKey: string | null;
|
||||||
limits: LimitConfigSnapshot;
|
limits: LimitConfigSnapshot;
|
||||||
sentryDsn: string;
|
sentryDsn: string;
|
||||||
sentryProxyPath: string;
|
|
||||||
sentryReportHost: string;
|
|
||||||
sentryProjectId: string;
|
|
||||||
sentryPublicKey: string;
|
|
||||||
relayDirectoryUrl: string | null;
|
relayDirectoryUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,10 +152,6 @@ class RuntimeConfigStore {
|
|||||||
currentDefaultsHash: string | null = null;
|
currentDefaultsHash: string | null = null;
|
||||||
|
|
||||||
sentryDsn: string = '';
|
sentryDsn: string = '';
|
||||||
sentryProxyPath: string = '/error-reporting-proxy';
|
|
||||||
sentryReportHost: string = '';
|
|
||||||
sentryProjectId: string = '';
|
|
||||||
sentryPublicKey: string = '';
|
|
||||||
|
|
||||||
relayDirectoryUrl: string | null = Config.PUBLIC_RELAY_DIRECTORY_URL;
|
relayDirectoryUrl: string | null = Config.PUBLIC_RELAY_DIRECTORY_URL;
|
||||||
|
|
||||||
@ -237,10 +225,6 @@ class RuntimeConfigStore {
|
|||||||
'limits',
|
'limits',
|
||||||
'currentDefaultsHash',
|
'currentDefaultsHash',
|
||||||
'sentryDsn',
|
'sentryDsn',
|
||||||
'sentryProxyPath',
|
|
||||||
'sentryReportHost',
|
|
||||||
'sentryProjectId',
|
|
||||||
'sentryPublicKey',
|
|
||||||
'relayDirectoryUrl',
|
'relayDirectoryUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -302,10 +286,6 @@ class RuntimeConfigStore {
|
|||||||
this.currentDefaultsHash = null;
|
this.currentDefaultsHash = null;
|
||||||
|
|
||||||
this.sentryDsn = snapshot.sentryDsn;
|
this.sentryDsn = snapshot.sentryDsn;
|
||||||
this.sentryProxyPath = snapshot.sentryProxyPath;
|
|
||||||
this.sentryReportHost = snapshot.sentryReportHost;
|
|
||||||
this.sentryProjectId = snapshot.sentryProjectId;
|
|
||||||
this.sentryPublicKey = snapshot.sentryPublicKey;
|
|
||||||
|
|
||||||
this.relayDirectoryUrl = snapshot.relayDirectoryUrl;
|
this.relayDirectoryUrl = snapshot.relayDirectoryUrl;
|
||||||
}
|
}
|
||||||
@ -331,10 +311,6 @@ class RuntimeConfigStore {
|
|||||||
publicPushVapidKey: this.publicPushVapidKey,
|
publicPushVapidKey: this.publicPushVapidKey,
|
||||||
limits: this.cloneLimits(this.limits),
|
limits: this.cloneLimits(this.limits),
|
||||||
sentryDsn: this.sentryDsn,
|
sentryDsn: this.sentryDsn,
|
||||||
sentryProxyPath: this.sentryProxyPath,
|
|
||||||
sentryReportHost: this.sentryReportHost,
|
|
||||||
sentryProjectId: this.sentryProjectId,
|
|
||||||
sentryPublicKey: this.sentryPublicKey,
|
|
||||||
relayDirectoryUrl: this.relayDirectoryUrl,
|
relayDirectoryUrl: this.relayDirectoryUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -472,10 +448,6 @@ class RuntimeConfigStore {
|
|||||||
|
|
||||||
if (instance.app_public) {
|
if (instance.app_public) {
|
||||||
this.sentryDsn = instance.app_public.sentry_dsn;
|
this.sentryDsn = instance.app_public.sentry_dsn;
|
||||||
this.sentryProxyPath = instance.app_public.sentry_proxy_path;
|
|
||||||
this.sentryReportHost = instance.app_public.sentry_report_host;
|
|
||||||
this.sentryProjectId = instance.app_public.sentry_project_id;
|
|
||||||
this.sentryPublicKey = instance.app_public.sentry_public_key;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,10 +114,6 @@ vi.mock('@app/lib/HttpClient', () => {
|
|||||||
limits: {version: 1, traitDefinitions: [], rules: []},
|
limits: {version: 1, traitDefinitions: [], rules: []},
|
||||||
app_public: {
|
app_public: {
|
||||||
sentry_dsn: '',
|
sentry_dsn: '',
|
||||||
sentry_proxy_path: '',
|
|
||||||
sentry_report_host: '',
|
|
||||||
sentry_project_id: '',
|
|
||||||
sentry_public_key: '',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -20,98 +20,19 @@
|
|||||||
import {loadConfig} from '@fluxer/config/src/ConfigLoader';
|
import {loadConfig} from '@fluxer/config/src/ConfigLoader';
|
||||||
import {extractBaseServiceConfig} from '@fluxer/config/src/ServiceConfigSlices';
|
import {extractBaseServiceConfig} from '@fluxer/config/src/ServiceConfigSlices';
|
||||||
|
|
||||||
function normalizeProxyPath(value: string | undefined): string {
|
|
||||||
const defaultPath = '/error-reporting-proxy';
|
|
||||||
let clean = (value ?? '').trim();
|
|
||||||
|
|
||||||
if (clean === '') {
|
|
||||||
return defaultPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clean.startsWith('/')) {
|
|
||||||
clean = `/${clean}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clean !== '/') {
|
|
||||||
clean = clean.replace(/\/+$/, '');
|
|
||||||
if (clean === '') {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSentryDsn(dsn: string | undefined): {
|
|
||||||
project_id: string;
|
|
||||||
public_key: string;
|
|
||||||
target_url: string;
|
|
||||||
path_prefix: string;
|
|
||||||
} | null {
|
|
||||||
if (!dsn?.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(dsn.trim());
|
|
||||||
|
|
||||||
if (!parsed.protocol || !parsed.host) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathPart = parsed.pathname.replace(/^\/+|\/+$/g, '');
|
|
||||||
const segments = pathPart ? pathPart.split('/') : [];
|
|
||||||
|
|
||||||
if (segments.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = segments[segments.length - 1]!;
|
|
||||||
const prefixSegments = segments.slice(0, -1);
|
|
||||||
const pathPrefix = prefixSegments.length > 0 ? `/${prefixSegments.join('/')}` : '';
|
|
||||||
|
|
||||||
const publicKey = parsed.username;
|
|
||||||
if (!publicKey) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
project_id: projectId,
|
|
||||||
public_key: publicKey,
|
|
||||||
target_url: `${parsed.protocol}//${parsed.host}`,
|
|
||||||
path_prefix: pathPrefix,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const master = await loadConfig();
|
const master = await loadConfig();
|
||||||
const appProxy = master.services.app_proxy;
|
const appProxy = master.services.app_proxy;
|
||||||
|
|
||||||
if (!appProxy?.kv) {
|
if (!appProxy) {
|
||||||
throw new Error('Application proxy requires `kv` configuration');
|
throw new Error('Application proxy requires `services.app_proxy` configuration');
|
||||||
}
|
|
||||||
|
|
||||||
if (!appProxy.rate_limit) {
|
|
||||||
throw new Error('Application proxy requires `rate_limit` configuration');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config = {
|
export const Config = {
|
||||||
...extractBaseServiceConfig(master),
|
...extractBaseServiceConfig(master),
|
||||||
port: appProxy.port,
|
port: appProxy.port,
|
||||||
static_cdn_endpoint: appProxy.static_cdn_endpoint,
|
static_cdn_endpoint: appProxy.static_cdn_endpoint,
|
||||||
sentry_proxy_path: normalizeProxyPath(appProxy.sentry_proxy_path),
|
sentry_dsn: master.app_public.sentry_dsn,
|
||||||
sentry_report_host: appProxy.sentry_report_host.replace(/\/+$/, ''),
|
|
||||||
sentry_proxy: parseSentryDsn(appProxy.sentry_dsn),
|
|
||||||
assets_dir: appProxy.assets_dir,
|
assets_dir: appProxy.assets_dir,
|
||||||
kv: {
|
|
||||||
url: appProxy.kv.url,
|
|
||||||
timeout_ms: appProxy.kv.timeout_ms ?? 5000,
|
|
||||||
},
|
|
||||||
rate_limit: {
|
|
||||||
sentry: appProxy.rate_limit.sentry,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Config = typeof Config;
|
export type Config = typeof Config;
|
||||||
|
|||||||
@ -22,53 +22,22 @@ import {shutdownInstrumentation} from '@app/Instrument';
|
|||||||
import {Logger} from '@app/Logger';
|
import {Logger} from '@app/Logger';
|
||||||
import {createAppProxyApp} from '@fluxer/app_proxy/src/App';
|
import {createAppProxyApp} from '@fluxer/app_proxy/src/App';
|
||||||
import {buildFluxerCSPOptions} from '@fluxer/app_proxy/src/app_server/utils/CSP';
|
import {buildFluxerCSPOptions} from '@fluxer/app_proxy/src/app_server/utils/CSP';
|
||||||
import {KVCacheProvider} from '@fluxer/cache/src/providers/KVCacheProvider';
|
|
||||||
import {createServiceTelemetry} from '@fluxer/hono/src/middleware/TelemetryAdapters';
|
import {createServiceTelemetry} from '@fluxer/hono/src/middleware/TelemetryAdapters';
|
||||||
import {createServer, setupGracefulShutdown} from '@fluxer/hono/src/Server';
|
import {createServer, setupGracefulShutdown} from '@fluxer/hono/src/Server';
|
||||||
import {KVClient} from '@fluxer/kv_client/src/KVClient';
|
|
||||||
import {throwKVRequiredError} from '@fluxer/rate_limit/src/KVRequiredError';
|
|
||||||
import {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
|
||||||
|
|
||||||
const telemetry = createServiceTelemetry({
|
const telemetry = createServiceTelemetry({
|
||||||
serviceName: 'fluxer-app-proxy',
|
serviceName: 'fluxer-app-proxy',
|
||||||
skipPaths: ['/_health'],
|
skipPaths: ['/_health'],
|
||||||
});
|
});
|
||||||
|
|
||||||
let rateLimitService: RateLimitService | null = null;
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
if (Config.kv.url) {
|
const cspDirectives = buildFluxerCSPOptions({sentryDsn: Config.sentry_dsn});
|
||||||
const kvClient = new KVClient({url: Config.kv.url, timeoutMs: Config.kv.timeout_ms});
|
|
||||||
const cacheService = new KVCacheProvider({client: kvClient});
|
|
||||||
rateLimitService = new RateLimitService(cacheService);
|
|
||||||
Logger.info({kvUrl: Config.kv.url}, 'KV-backed rate limiting enabled');
|
|
||||||
} else {
|
|
||||||
throwKVRequiredError({
|
|
||||||
serviceName: 'fluxer_app_proxy',
|
|
||||||
configPath: 'Config.kv.url',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cspDirectives = buildFluxerCSPOptions({
|
|
||||||
sentryProxy: Config.sentry_proxy
|
|
||||||
? {
|
|
||||||
projectId: Config.sentry_proxy.project_id,
|
|
||||||
publicKey: Config.sentry_proxy.public_key,
|
|
||||||
targetUrl: Config.sentry_proxy.target_url,
|
|
||||||
pathPrefix: Config.sentry_proxy.path_prefix,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
sentryProxyPath: Config.sentry_proxy_path,
|
|
||||||
sentryReportHost: Config.sentry_report_host,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {app, shutdown} = await createAppProxyApp({
|
const {app, shutdown} = await createAppProxyApp({
|
||||||
config: Config,
|
config: Config,
|
||||||
cspDirectives,
|
cspDirectives,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
rateLimitService,
|
|
||||||
metricsCollector: telemetry.metricsCollector,
|
metricsCollector: telemetry.metricsCollector,
|
||||||
sentryProxyPath: Config.sentry_proxy_path,
|
|
||||||
staticCDNEndpoint: Config.static_cdn_endpoint,
|
staticCDNEndpoint: Config.static_cdn_endpoint,
|
||||||
staticDir: Config.assets_dir,
|
staticDir: Config.assets_dir,
|
||||||
tracing: telemetry.tracing,
|
tracing: telemetry.tracing,
|
||||||
|
|||||||
@ -60902,30 +60902,10 @@
|
|||||||
"sentry_dsn": {
|
"sentry_dsn": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Sentry DSN for client-side error reporting"
|
"description": "Sentry DSN for client-side error reporting"
|
||||||
},
|
|
||||||
"sentry_proxy_path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Proxy path for Sentry requests"
|
|
||||||
},
|
|
||||||
"sentry_report_host": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Host for Sentry error reports"
|
|
||||||
},
|
|
||||||
"sentry_project_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Sentry project ID"
|
|
||||||
},
|
|
||||||
"sentry_public_key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Sentry public key"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"sentry_dsn",
|
"sentry_dsn"
|
||||||
"sentry_proxy_path",
|
|
||||||
"sentry_report_host",
|
|
||||||
"sentry_project_id",
|
|
||||||
"sentry_public_key"
|
|
||||||
],
|
],
|
||||||
"description": "Public application configuration for client-side features"
|
"description": "Public application configuration for client-side features"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -35,10 +35,6 @@ Public application configuration for client-side features
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| sentry_dsn | string | Sentry DSN for client-side error reporting |
|
| sentry_dsn | string | Sentry DSN for client-side error reporting |
|
||||||
| sentry_project_id | [SnowflakeType](#snowflaketype) | Sentry project ID |
|
|
||||||
| sentry_proxy_path | string | Proxy path for Sentry requests |
|
|
||||||
| sentry_public_key | string | Sentry public key |
|
|
||||||
| sentry_report_host | string | Host for Sentry error reports |
|
|
||||||
|
|
||||||
<a id="wellknownfluxerresponsecaptcha"></a>
|
<a id="wellknownfluxerresponsecaptcha"></a>
|
||||||
|
|
||||||
@ -154,4 +150,3 @@ Push notification configuration
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| public_vapid_key | ?string | VAPID public key for web push notifications |
|
| public_vapid_key | ?string | VAPID public key for web push notifications |
|
||||||
|
|
||||||
|
|||||||
@ -18,10 +18,10 @@ description: 'config.json reference for self-hosted Fluxer.'
|
|||||||
- [csam](#csam)
|
- [csam](#csam)
|
||||||
- [database](#database)
|
- [database](#database)
|
||||||
- [dev](#dev)
|
- [dev](#dev)
|
||||||
|
- [discovery](#discovery)
|
||||||
- [domain](#domain)
|
- [domain](#domain)
|
||||||
- [endpoint_overrides](#endpoint-overrides)
|
- [endpoint_overrides](#endpoint-overrides)
|
||||||
- [federation](#federation)
|
- [federation](#federation)
|
||||||
- [gateway_connection](#gateway-connection)
|
|
||||||
- [geoip](#geoip)
|
- [geoip](#geoip)
|
||||||
- [instance](#instance)
|
- [instance](#instance)
|
||||||
- [integrations](#integrations)
|
- [integrations](#integrations)
|
||||||
@ -59,11 +59,11 @@ These are the top-level configuration options in your `config.json`.
|
|||||||
| database | [database](#database) | Primary database configuration. Selects the backend (Cassandra vs SQLite) and provides connection details. |
|
| database | [database](#database) | Primary database configuration. Selects the backend (Cassandra vs SQLite) and provides connection details. |
|
||||||
| deletion_grace_period_hours? | number | Grace period in hours before soft-deleted items are permanently removed. Default: `72` |
|
| deletion_grace_period_hours? | number | Grace period in hours before soft-deleted items are permanently removed. Default: `72` |
|
||||||
| dev? | [dev](#dev) | Development-only overrides and flags. These should generally be disabled in production. Default: `{}` |
|
| dev? | [dev](#dev) | Development-only overrides and flags. These should generally be disabled in production. Default: `{}` |
|
||||||
|
| discovery? | [discovery](#discovery) | Guild discovery listing configuration. Default: `{}` |
|
||||||
| domain | [domain](#domain) | Global domain and port configuration used to derive public endpoints for all services. |
|
| domain | [domain](#domain) | Global domain and port configuration used to derive public endpoints for all services. |
|
||||||
| endpoint_overrides? | [endpoint_overrides](#endpoint-overrides) | Manual overrides for specific public endpoints. If set, these take precedence over automatically derived URLs. |
|
| endpoint_overrides? | [endpoint_overrides](#endpoint-overrides) | Manual overrides for specific public endpoints. If set, these take precedence over automatically derived URLs. |
|
||||||
| env | enum<`development`, `production`, `test`> | Runtime environment for the application. Controls behavior such as logging verbosity, error details, and optimization levels. |
|
| env | enum<`development`, `production`, `test`> | Runtime environment for the application. Controls behavior such as logging verbosity, error details, and optimization levels. |
|
||||||
| federation? | [federation](#federation) | Federation configuration for connecting with other Fluxer instances. Default: `{}` |
|
| federation? | [federation](#federation) | Federation configuration for connecting with other Fluxer instances. Default: `{}` |
|
||||||
| gateway | [gateway_connection](#gateway-connection) | Configuration for the real-time Gateway service connection. |
|
|
||||||
| geoip? | [geoip](#geoip) | GeoIP database configuration. Default: `{}` |
|
| geoip? | [geoip](#geoip) | GeoIP database configuration. Default: `{}` |
|
||||||
| inactivity_deletion_threshold_days? | number | Days of inactivity after which data may be subject to deletion. Default: `365` |
|
| inactivity_deletion_threshold_days? | number | Days of inactivity after which data may be subject to deletion. Default: `365` |
|
||||||
| instance? | [instance](#instance) | Instance-specific settings and policies. Default: `{}` |
|
| instance? | [instance](#instance) | Instance-specific settings and policies. Default: `{}` |
|
||||||
@ -117,10 +117,6 @@ Public configuration exposed to the frontend application.
|
|||||||
| bootstrap_api_endpoint? | string | Bootstrap API endpoint. Default: `""` |
|
| bootstrap_api_endpoint? | string | Bootstrap API endpoint. Default: `""` |
|
||||||
| bootstrap_api_public_endpoint? | string | Public Bootstrap API endpoint. Default: `""` |
|
| bootstrap_api_public_endpoint? | string | Public Bootstrap API endpoint. Default: `""` |
|
||||||
| sentry_dsn? | string | Frontend Sentry DSN. Default: `""` |
|
| sentry_dsn? | string | Frontend Sentry DSN. Default: `""` |
|
||||||
| sentry_project_id? | string | Sentry Project ID. Default: `""` |
|
|
||||||
| sentry_proxy_path? | string | Path to proxy Sentry requests. Default: `/error-reporting-proxy` |
|
|
||||||
| sentry_public_key? | string | Sentry Public Key. Default: `""` |
|
|
||||||
| sentry_report_host? | string | Host for Sentry reporting. Default: `""` |
|
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
<Expandable title="Example JSON">
|
||||||
```json
|
```json
|
||||||
@ -128,11 +124,7 @@ Public configuration exposed to the frontend application.
|
|||||||
"api_version": 1,
|
"api_version": 1,
|
||||||
"bootstrap_api_endpoint": "",
|
"bootstrap_api_endpoint": "",
|
||||||
"bootstrap_api_public_endpoint": "",
|
"bootstrap_api_public_endpoint": "",
|
||||||
"sentry_dsn": "",
|
"sentry_dsn": ""
|
||||||
"sentry_project_id": "",
|
|
||||||
"sentry_proxy_path": "/error-reporting-proxy",
|
|
||||||
"sentry_public_key": "",
|
|
||||||
"sentry_report_host": ""
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</Expandable>
|
</Expandable>
|
||||||
@ -383,6 +375,30 @@ Development environment flags.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## discovery
|
||||||
|
|
||||||
|
<a id="discovery"></a>
|
||||||
|
|
||||||
|
JSON path: `discovery`
|
||||||
|
|
||||||
|
Guild discovery listing configuration.
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| enabled? | boolean | Whether guild discovery is enabled on this instance. Default: `true` |
|
||||||
|
| min_member_count? | number | Minimum number of members a guild needs before it can apply for discovery listing. Default: `1` |
|
||||||
|
|
||||||
|
<Expandable title="Example JSON">
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"min_member_count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Expandable>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## domain
|
## domain
|
||||||
|
|
||||||
<a id="domain"></a>
|
<a id="domain"></a>
|
||||||
@ -481,30 +497,6 @@ Federation configuration for connecting with other Fluxer instances.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## gateway_connection
|
|
||||||
|
|
||||||
<a id="gateway-connection"></a>
|
|
||||||
|
|
||||||
JSON path: `gateway`
|
|
||||||
|
|
||||||
Configuration for backend services to call the Gateway via RPC.
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
|----------|------|-------------|
|
|
||||||
| rpc_endpoint? | string | Gateway RPC endpoint URL (e.g. http://gateway:8080). Default: `http://127.0.0.1:8088` |
|
|
||||||
| rpc_secret | string | Shared secret for authenticating RPC calls to the Gateway. |
|
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rpc_secret": "your_rpc_secret",
|
|
||||||
"rpc_endpoint": "http://127.0.0.1:8088"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</Expandable>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## geoip
|
## geoip
|
||||||
|
|
||||||
<a id="geoip"></a>
|
<a id="geoip"></a>
|
||||||
@ -1162,6 +1154,7 @@ Container for all service-specific configurations.
|
|||||||
| gateway | [gateway_service](#gateway-service) | |
|
| gateway | [gateway_service](#gateway-service) | |
|
||||||
| marketing? | [marketing_service](#marketing-service) | |
|
| marketing? | [marketing_service](#marketing-service) | |
|
||||||
| media_proxy | [media_proxy_service](#media-proxy-service) | |
|
| media_proxy | [media_proxy_service](#media-proxy-service) | |
|
||||||
|
| nats? | [nats_services](#nats-services) | Default: `{}` |
|
||||||
| queue? | [queue_service](#queue-service) | Default: `{}` |
|
| queue? | [queue_service](#queue-service) | Default: `{}` |
|
||||||
| s3? | [s3_service](#s3-service) | Default: `{}` |
|
| s3? | [s3_service](#s3-service) | Default: `{}` |
|
||||||
| server? | [server_service](#server-service) | Default: `{}` |
|
| server? | [server_service](#server-service) | Default: `{}` |
|
||||||
@ -1211,6 +1204,28 @@ Rate limiting parameters.
|
|||||||
```
|
```
|
||||||
</Expandable>
|
</Expandable>
|
||||||
|
|
||||||
|
### nats_services
|
||||||
|
|
||||||
|
JSON path: `services.nats`
|
||||||
|
|
||||||
|
Configuration for NATS messaging.
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| auth_token? | string | Authentication token for NATS connections. Default: `""` |
|
||||||
|
| core_url? | string | NATS Core server URL for RPC. Default: `nats://127.0.0.1:4222` |
|
||||||
|
| jetstream_url? | string | NATS JetStream server URL for job queues. Default: `nats://127.0.0.1:4223` |
|
||||||
|
|
||||||
|
<Expandable title="Example JSON">
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth_token": "",
|
||||||
|
"core_url": "nats://127.0.0.1:4222",
|
||||||
|
"jetstream_url": "nats://127.0.0.1:4223"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Expandable>
|
||||||
|
|
||||||
### queue_service
|
### queue_service
|
||||||
|
|
||||||
JSON path: `services.queue`
|
JSON path: `services.queue`
|
||||||
@ -1219,37 +1234,26 @@ Configuration for the Job Queue service.
|
|||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|------|-------------|
|
||||||
| command_buffer? | number | Size of the internal command buffer. Default: `1000` |
|
| concurrency? | number | Number of concurrent worker threads. Default: `1` |
|
||||||
| concurrency? | number | Number of concurrent worker threads. Default: `2` |
|
|
||||||
| data_dir? | string | Filesystem path to store queue data. Default: `./data/queue` |
|
| data_dir? | string | Filesystem path to store queue data. Default: `./data/queue` |
|
||||||
| default_visibility_timeout_ms? | number | Default time in milliseconds a message remains invisible after being received. Default: `30000` |
|
| default_visibility_timeout_ms? | number | Default time in milliseconds a message remains invisible after being received. Default: `30000` |
|
||||||
| export_timeout? | number | Timeout in milliseconds for data export operations. Default: `30000` |
|
| port? | number | Port to listen on. Default: `8088` |
|
||||||
| host? | string | Network interface to bind to. Default: `0.0.0.0` |
|
| secret? | string | Secret for queue API authentication. Default: `""` |
|
||||||
| max_receive_batch? | number | Maximum number of messages to retrieve in a single batch. Default: `10` |
|
|
||||||
| port? | number | Port to listen on. Default: `8080` |
|
|
||||||
| rate_limit? | [rate_limit](#rate-limit) | Rate limiting configuration for the Queue service. |
|
|
||||||
| secret? | string | Authentication secret for the Queue service. |
|
|
||||||
| snapshot_after_ops? | number | Number of operations after which to take a queue snapshot. Default: `10000` |
|
| snapshot_after_ops? | number | Number of operations after which to take a queue snapshot. Default: `10000` |
|
||||||
| snapshot_every_ms? | number | Interval in milliseconds to take queue snapshots. Default: `60000` |
|
| snapshot_every_ms? | number | Interval in milliseconds to take queue snapshots. Default: `60000` |
|
||||||
| snapshot_zstd_level? | number | Zstd compression level for snapshots (1-22). Default: `3` |
|
| snapshot_zstd_level? | number | Zstd compression level for snapshots (1-22). Default: `3` |
|
||||||
| visibility_timeout_backoff_ms? | number | Backoff duration in milliseconds for visibility timeouts. Default: `1000` |
|
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
<Expandable title="Example JSON">
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"command_buffer": 1000,
|
"concurrency": 1,
|
||||||
"concurrency": 2,
|
|
||||||
"data_dir": "./data/queue",
|
"data_dir": "./data/queue",
|
||||||
"default_visibility_timeout_ms": 30000,
|
"default_visibility_timeout_ms": 30000,
|
||||||
"export_timeout": 30000,
|
"port": 8088,
|
||||||
"host": "0.0.0.0",
|
|
||||||
"max_receive_batch": 10,
|
|
||||||
"port": 8080,
|
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"snapshot_after_ops": 10000,
|
"snapshot_after_ops": 10000,
|
||||||
"snapshot_every_ms": 60000,
|
"snapshot_every_ms": 60000,
|
||||||
"snapshot_zstd_level": 3,
|
"snapshot_zstd_level": 3
|
||||||
"visibility_timeout_backoff_ms": 1000
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</Expandable>
|
</Expandable>
|
||||||
@ -1366,77 +1370,19 @@ Configuration for the App Proxy service (frontend server).
|
|||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|------|-------------|
|
||||||
| assets_dir? | string | Filesystem directory containing static assets. Default: `./assets` |
|
| assets_dir? | string | Filesystem directory containing static assets. Default: `./assets` |
|
||||||
| kv? | [app_proxy_kv](#app-proxy-kv) | Valkey/Redis configuration for the proxy. |
|
|
||||||
| port? | number | Port to listen on. Default: `8773` |
|
| port? | number | Port to listen on. Default: `8773` |
|
||||||
| rate_limit? | [app_proxy_rate_limit](#app-proxy-rate-limit) | Rate limiting configuration for the App Proxy. Default: `{}` |
|
|
||||||
| sentry_dsn | string | Sentry DSN (Data Source Name) for frontend error tracking. |
|
|
||||||
| sentry_proxy_path? | string | URL path for proxying Sentry requests. Default: `/error-reporting-proxy` |
|
|
||||||
| sentry_report_host | string | Hostname to which Sentry reports should be sent. |
|
|
||||||
| static_cdn_endpoint? | string | URL endpoint for serving static assets via CDN. Default: `""` |
|
| static_cdn_endpoint? | string | URL endpoint for serving static assets via CDN. Default: `""` |
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
<Expandable title="Example JSON">
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sentry_dsn": "your_sentry_dsn",
|
|
||||||
"sentry_report_host": "your_sentry_report_host",
|
|
||||||
"assets_dir": "./assets",
|
"assets_dir": "./assets",
|
||||||
"port": 8773,
|
"port": 8773,
|
||||||
"sentry_proxy_path": "/error-reporting-proxy",
|
|
||||||
"static_cdn_endpoint": ""
|
"static_cdn_endpoint": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</Expandable>
|
</Expandable>
|
||||||
|
|
||||||
### app_proxy_kv
|
|
||||||
|
|
||||||
JSON path: `services.app_proxy.kv`
|
|
||||||
|
|
||||||
Valkey/Redis settings for the App Proxy.
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
|----------|------|-------------|
|
|
||||||
| timeout_ms? | number | Request timeout for Valkey/Redis in milliseconds. Default: `5000` |
|
|
||||||
| url | string | Full URL to Valkey/Redis. |
|
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "your_url",
|
|
||||||
"timeout_ms": 5000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</Expandable>
|
|
||||||
|
|
||||||
### app_proxy_rate_limit
|
|
||||||
|
|
||||||
JSON path: `services.app_proxy.rate_limit`
|
|
||||||
|
|
||||||
Rate limit settings for the App Proxy.
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
|----------|------|-------------|
|
|
||||||
| sentry? | [app_proxy_sentry_rate_limit](#app-proxy-sentry-rate-limit) | Sentry reporting rate limit configuration. Default: `{}` |
|
|
||||||
|
|
||||||
### app_proxy_sentry_rate_limit
|
|
||||||
|
|
||||||
JSON path: `services.app_proxy.rate_limit.sentry`
|
|
||||||
|
|
||||||
Rate limiting for Sentry error reporting requests.
|
|
||||||
|
|
||||||
| Property | Type | Description |
|
|
||||||
|----------|------|-------------|
|
|
||||||
| limit? | number | Number of Sentry requests allowed per window. Default: `100` |
|
|
||||||
| window_ms? | number | Time window for Sentry rate limiting in milliseconds. Default: `1000` |
|
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"limit": 100,
|
|
||||||
"window_ms": 1000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</Expandable>
|
|
||||||
|
|
||||||
### gateway_service
|
### gateway_service
|
||||||
|
|
||||||
JSON path: `services.gateway`
|
JSON path: `services.gateway`
|
||||||
@ -1446,7 +1392,6 @@ Configuration for the Gateway service (WebSocket).
|
|||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|------|-------------|
|
||||||
| admin_reload_secret | string | Secret used to trigger code hot-swapping/reloads. |
|
| admin_reload_secret | string | Secret used to trigger code hot-swapping/reloads. |
|
||||||
| api_host | string | Host/Port of the API service to communicate with. |
|
|
||||||
| gateway_metrics_enabled? | boolean | Enable collection of gateway metrics. Default: `false` |
|
| gateway_metrics_enabled? | boolean | Enable collection of gateway metrics. Default: `false` |
|
||||||
| gateway_metrics_report_interval_ms? | number | Interval in milliseconds to report gateway metrics. Default: `30000` |
|
| gateway_metrics_report_interval_ms? | number | Interval in milliseconds to report gateway metrics. Default: `30000` |
|
||||||
| guild_shards? | number | Number of shards for guild handling. Default: `1` |
|
| guild_shards? | number | Number of shards for guild handling. Default: `1` |
|
||||||
@ -1464,13 +1409,11 @@ Configuration for the Gateway service (WebSocket).
|
|||||||
| push_subscriptions_cache_mb? | number | Memory cache size (MB) for push subscriptions. Default: `1024` |
|
| push_subscriptions_cache_mb? | number | Memory cache size (MB) for push subscriptions. Default: `1024` |
|
||||||
| push_user_guild_settings_cache_mb? | number | Memory cache size (MB) for user guild settings. Default: `1024` |
|
| push_user_guild_settings_cache_mb? | number | Memory cache size (MB) for user guild settings. Default: `1024` |
|
||||||
| release_node? | string | Erlang node name for the release. Default: `fluxer_gateway@gateway` |
|
| release_node? | string | Erlang node name for the release. Default: `fluxer_gateway@gateway` |
|
||||||
| rpc_tcp_port? | number | Port for API-to-Gateway internal RPC over TCP. Default: `8772` |
|
|
||||||
|
|
||||||
<Expandable title="Example JSON">
|
<Expandable title="Example JSON">
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"admin_reload_secret": "your_admin_reload_secret",
|
"admin_reload_secret": "your_admin_reload_secret",
|
||||||
"api_host": "your_api_host",
|
|
||||||
"media_proxy_endpoint": "your_media_proxy_endpoint",
|
"media_proxy_endpoint": "your_media_proxy_endpoint",
|
||||||
"gateway_metrics_enabled": false,
|
"gateway_metrics_enabled": false,
|
||||||
"gateway_metrics_report_interval_ms": 30000,
|
"gateway_metrics_report_interval_ms": 30000,
|
||||||
@ -1487,8 +1430,7 @@ Configuration for the Gateway service (WebSocket).
|
|||||||
"push_enabled": true,
|
"push_enabled": true,
|
||||||
"push_subscriptions_cache_mb": 1024,
|
"push_subscriptions_cache_mb": 1024,
|
||||||
"push_user_guild_settings_cache_mb": 1024,
|
"push_user_guild_settings_cache_mb": 1024,
|
||||||
"release_node": "fluxer_gateway@gateway",
|
"release_node": "fluxer_gateway@gateway"
|
||||||
"rpc_tcp_port": 8772
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</Expandable>
|
</Expandable>
|
||||||
|
|||||||
@ -184,7 +184,7 @@ export const DiscoveryPage: FC<DiscoveryPageProps> = ({
|
|||||||
<TableRow key={app.guild_id}>
|
<TableRow key={app.guild_id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<a
|
<a
|
||||||
href={`${config.basePath}/guilds?query=${app.guild_id}`}
|
href={`${config.basePath}/guilds/${app.guild_id}`}
|
||||||
class="font-mono text-blue-600 text-sm hover:underline"
|
class="font-mono text-blue-600 text-sm hover:underline"
|
||||||
>
|
>
|
||||||
{app.guild_id}
|
{app.guild_id}
|
||||||
|
|||||||
@ -308,10 +308,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
|||||||
|
|
||||||
appPublic: {
|
appPublic: {
|
||||||
sentryDsn: master.app_public.sentry_dsn,
|
sentryDsn: master.app_public.sentry_dsn,
|
||||||
sentryProxyPath: master.app_public.sentry_proxy_path,
|
|
||||||
sentryReportHost: master.app_public.sentry_report_host,
|
|
||||||
sentryProjectId: master.app_public.sentry_project_id,
|
|
||||||
sentryPublicKey: master.app_public.sentry_public_key,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@ -96,10 +96,6 @@ export interface APIConfig {
|
|||||||
|
|
||||||
appPublic: {
|
appPublic: {
|
||||||
sentryDsn: string;
|
sentryDsn: string;
|
||||||
sentryProxyPath: string;
|
|
||||||
sentryReportHost: string;
|
|
||||||
sentryProjectId: string;
|
|
||||||
sentryPublicKey: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
email: {
|
email: {
|
||||||
|
|||||||
@ -95,10 +95,6 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
|||||||
},
|
},
|
||||||
app_public: {
|
app_public: {
|
||||||
sentry_dsn: Config.appPublic.sentryDsn,
|
sentry_dsn: Config.appPublic.sentryDsn,
|
||||||
sentry_proxy_path: Config.appPublic.sentryProxyPath,
|
|
||||||
sentry_report_host: Config.appPublic.sentryReportHost,
|
|
||||||
sentry_project_id: Config.appPublic.sentryProjectId,
|
|
||||||
sentry_public_key: Config.appPublic.sentryPublicKey,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -30,15 +30,10 @@ export interface AppProxyResult {
|
|||||||
export async function createAppProxyApp(options: CreateAppProxyAppOptions): Promise<AppProxyResult> {
|
export async function createAppProxyApp(options: CreateAppProxyAppOptions): Promise<AppProxyResult> {
|
||||||
const {
|
const {
|
||||||
assetsPath = '/assets',
|
assetsPath = '/assets',
|
||||||
config,
|
|
||||||
cspDirectives,
|
cspDirectives,
|
||||||
customMiddleware = [],
|
customMiddleware = [],
|
||||||
logger,
|
logger,
|
||||||
metricsCollector,
|
metricsCollector,
|
||||||
rateLimitService = null,
|
|
||||||
sentryProxyEnabled = true,
|
|
||||||
sentryProxyPath = '/sentry',
|
|
||||||
sentryProxyRouteEnabled = true,
|
|
||||||
staticCDNEndpoint,
|
staticCDNEndpoint,
|
||||||
staticDir,
|
staticDir,
|
||||||
tracing,
|
tracing,
|
||||||
@ -48,13 +43,9 @@ export async function createAppProxyApp(options: CreateAppProxyAppOptions): Prom
|
|||||||
|
|
||||||
applyAppProxyMiddleware({
|
applyAppProxyMiddleware({
|
||||||
app,
|
app,
|
||||||
config,
|
|
||||||
customMiddleware,
|
customMiddleware,
|
||||||
logger,
|
logger,
|
||||||
metricsCollector,
|
metricsCollector,
|
||||||
rateLimitService,
|
|
||||||
sentryProxyEnabled,
|
|
||||||
sentryProxyPath,
|
|
||||||
tracing,
|
tracing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -63,9 +54,6 @@ export async function createAppProxyApp(options: CreateAppProxyAppOptions): Prom
|
|||||||
assetsPath,
|
assetsPath,
|
||||||
cspDirectives,
|
cspDirectives,
|
||||||
logger,
|
logger,
|
||||||
sentryProxy: config.sentry_proxy,
|
|
||||||
sentryProxyPath,
|
|
||||||
sentryProxyRouteEnabled,
|
|
||||||
staticCDNEndpoint,
|
staticCDNEndpoint,
|
||||||
staticDir,
|
staticDir,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -22,34 +22,13 @@ import type {SentryConfig, TelemetryConfig} from '@fluxer/config/src/MasterZodSc
|
|||||||
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
|
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
|
||||||
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
|
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
|
||||||
import type {Logger} from '@fluxer/logger/src/Logger';
|
import type {Logger} from '@fluxer/logger/src/Logger';
|
||||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
|
||||||
import type {MiddlewareHandler} from 'hono';
|
import type {MiddlewareHandler} from 'hono';
|
||||||
|
|
||||||
export interface AppProxySentryProxyConfig {
|
|
||||||
project_id: string;
|
|
||||||
public_key: string;
|
|
||||||
target_url: string;
|
|
||||||
path_prefix: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppProxyConfig {
|
export interface AppProxyConfig {
|
||||||
env: string;
|
env: string;
|
||||||
port: number;
|
port: number;
|
||||||
static_cdn_endpoint: string;
|
static_cdn_endpoint: string;
|
||||||
sentry_proxy_path: string;
|
|
||||||
sentry_report_host: string;
|
|
||||||
sentry_proxy: AppProxySentryProxyConfig | null;
|
|
||||||
assets_dir: string;
|
assets_dir: string;
|
||||||
kv: {
|
|
||||||
url: string;
|
|
||||||
timeout_ms: number;
|
|
||||||
};
|
|
||||||
rate_limit: {
|
|
||||||
sentry: {
|
|
||||||
limit: number;
|
|
||||||
window_ms: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
telemetry: TelemetryConfig;
|
telemetry: TelemetryConfig;
|
||||||
sentry: SentryConfig;
|
sentry: SentryConfig;
|
||||||
}
|
}
|
||||||
@ -57,7 +36,6 @@ export interface AppProxyConfig {
|
|||||||
export interface AppProxyContext {
|
export interface AppProxyContext {
|
||||||
config: AppProxyConfig;
|
config: AppProxyConfig;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
rateLimitService: IRateLimitService | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppProxyHonoEnv {
|
export interface AppProxyHonoEnv {
|
||||||
@ -69,15 +47,11 @@ export type AppProxyMiddleware = MiddlewareHandler<AppProxyHonoEnv>;
|
|||||||
export interface CreateAppProxyAppOptions {
|
export interface CreateAppProxyAppOptions {
|
||||||
config: AppProxyConfig;
|
config: AppProxyConfig;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
rateLimitService?: IRateLimitService | null;
|
|
||||||
metricsCollector?: MetricsCollector;
|
metricsCollector?: MetricsCollector;
|
||||||
tracing?: TracingOptions;
|
tracing?: TracingOptions;
|
||||||
customMiddleware?: Array<AppProxyMiddleware>;
|
customMiddleware?: Array<AppProxyMiddleware>;
|
||||||
sentryProxyPath?: string;
|
|
||||||
assetsPath?: string;
|
assetsPath?: string;
|
||||||
staticCDNEndpoint?: string;
|
staticCDNEndpoint?: string;
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
cspDirectives?: CSPOptions;
|
cspDirectives?: CSPOptions;
|
||||||
sentryProxyEnabled?: boolean;
|
|
||||||
sentryProxyRouteEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,44 +17,25 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {AppProxyConfig, AppProxyHonoEnv, AppProxyMiddleware} from '@fluxer/app_proxy/src/AppProxyTypes';
|
import type {AppProxyHonoEnv, AppProxyMiddleware} from '@fluxer/app_proxy/src/AppProxyTypes';
|
||||||
import {createProxyRateLimitMiddleware} from '@fluxer/app_proxy/src/app_proxy/middleware/ProxyRateLimit';
|
|
||||||
import {createSentryHostProxyMiddleware} from '@fluxer/app_proxy/src/app_proxy/middleware/SentryHostProxy';
|
|
||||||
import {proxySentry} from '@fluxer/app_proxy/src/app_proxy/proxy/SentryProxy';
|
|
||||||
import {resolveSentryHost} from '@fluxer/app_proxy/src/app_proxy/utils/Host';
|
|
||||||
import {isExpectedError} from '@fluxer/app_proxy/src/ErrorClassification';
|
import {isExpectedError} from '@fluxer/app_proxy/src/ErrorClassification';
|
||||||
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
|
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
|
||||||
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
|
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
|
||||||
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
|
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
|
||||||
import type {Logger} from '@fluxer/logger/src/Logger';
|
import type {Logger} from '@fluxer/logger/src/Logger';
|
||||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
|
||||||
import {captureException} from '@fluxer/sentry/src/Sentry';
|
import {captureException} from '@fluxer/sentry/src/Sentry';
|
||||||
import type {Context, Hono} from 'hono';
|
import type {Context, Hono} from 'hono';
|
||||||
|
|
||||||
interface ApplyAppProxyMiddlewareOptions {
|
interface ApplyAppProxyMiddlewareOptions {
|
||||||
app: Hono<AppProxyHonoEnv>;
|
app: Hono<AppProxyHonoEnv>;
|
||||||
config: AppProxyConfig;
|
|
||||||
customMiddleware: Array<AppProxyMiddleware>;
|
customMiddleware: Array<AppProxyMiddleware>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
metricsCollector?: MetricsCollector;
|
metricsCollector?: MetricsCollector;
|
||||||
rateLimitService: IRateLimitService | null;
|
|
||||||
sentryProxyEnabled: boolean;
|
|
||||||
sentryProxyPath: string;
|
|
||||||
tracing?: TracingOptions;
|
tracing?: TracingOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions): void {
|
export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions): void {
|
||||||
const {
|
const {app, customMiddleware, logger, metricsCollector, tracing} = options;
|
||||||
app,
|
|
||||||
config,
|
|
||||||
customMiddleware,
|
|
||||||
logger,
|
|
||||||
metricsCollector,
|
|
||||||
rateLimitService,
|
|
||||||
sentryProxyEnabled,
|
|
||||||
sentryProxyPath,
|
|
||||||
tracing,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
applyMiddlewareStack(app, {
|
applyMiddlewareStack(app, {
|
||||||
requestId: {},
|
requestId: {},
|
||||||
@ -81,7 +62,7 @@ export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions)
|
|||||||
skip: ['/_health'],
|
skip: ['/_health'],
|
||||||
},
|
},
|
||||||
errorHandler: {
|
errorHandler: {
|
||||||
includeStack: !sentryProxyEnabled,
|
includeStack: true,
|
||||||
logger: (error: Error, ctx: Context) => {
|
logger: (error: Error, ctx: Context) => {
|
||||||
if (!isExpectedError(error)) {
|
if (!isExpectedError(error)) {
|
||||||
captureException(error, {
|
captureException(error, {
|
||||||
@ -103,58 +84,4 @@ export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions)
|
|||||||
},
|
},
|
||||||
customMiddleware,
|
customMiddleware,
|
||||||
});
|
});
|
||||||
|
|
||||||
applySentryHostProxyMiddleware({
|
|
||||||
app,
|
|
||||||
config,
|
|
||||||
logger,
|
|
||||||
rateLimitService,
|
|
||||||
sentryProxyEnabled,
|
|
||||||
sentryProxyPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApplySentryHostProxyMiddlewareOptions {
|
|
||||||
app: Hono<AppProxyHonoEnv>;
|
|
||||||
config: AppProxyConfig;
|
|
||||||
logger: Logger;
|
|
||||||
rateLimitService: IRateLimitService | null;
|
|
||||||
sentryProxyEnabled: boolean;
|
|
||||||
sentryProxyPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySentryHostProxyMiddleware(options: ApplySentryHostProxyMiddlewareOptions): void {
|
|
||||||
const {app, config, logger, rateLimitService, sentryProxyEnabled, sentryProxyPath} = options;
|
|
||||||
const sentryHost = resolveSentryHost(config);
|
|
||||||
const sentryProxy = config.sentry_proxy;
|
|
||||||
|
|
||||||
if (!sentryProxyEnabled || !sentryHost || !sentryProxy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentryRateLimitMiddleware = createProxyRateLimitMiddleware({
|
|
||||||
rateLimitService,
|
|
||||||
bucketPrefix: 'sentry-proxy',
|
|
||||||
config: {
|
|
||||||
enabled: config.rate_limit.sentry.limit > 0,
|
|
||||||
limit: config.rate_limit.sentry.limit,
|
|
||||||
windowMs: config.rate_limit.sentry.window_ms,
|
|
||||||
skipPaths: ['/_health'],
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sentryHostMiddleware = createSentryHostProxyMiddleware({
|
|
||||||
sentryHost,
|
|
||||||
rateLimitMiddleware: sentryRateLimitMiddleware,
|
|
||||||
proxyHandler: (c) =>
|
|
||||||
proxySentry(c, {
|
|
||||||
enabled: true,
|
|
||||||
logger,
|
|
||||||
sentryProxy,
|
|
||||||
sentryProxyPath,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('*', sentryHostMiddleware);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,9 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {resolve} from 'node:path';
|
import {resolve} from 'node:path';
|
||||||
import type {AppProxyHonoEnv, AppProxySentryProxyConfig} from '@fluxer/app_proxy/src/AppProxyTypes';
|
import type {AppProxyHonoEnv} from '@fluxer/app_proxy/src/AppProxyTypes';
|
||||||
import {proxyAssets} from '@fluxer/app_proxy/src/app_proxy/proxy/AssetsProxy';
|
import {proxyAssets} from '@fluxer/app_proxy/src/app_proxy/proxy/AssetsProxy';
|
||||||
import {proxySentry} from '@fluxer/app_proxy/src/app_proxy/proxy/SentryProxy';
|
|
||||||
import {createSpaIndexRoute} from '@fluxer/app_proxy/src/app_server/routes/SpaIndexRoute';
|
import {createSpaIndexRoute} from '@fluxer/app_proxy/src/app_server/routes/SpaIndexRoute';
|
||||||
import type {CSPOptions} from '@fluxer/app_proxy/src/app_server/utils/CSP';
|
import type {CSPOptions} from '@fluxer/app_proxy/src/app_server/utils/CSP';
|
||||||
import type {Logger} from '@fluxer/logger/src/Logger';
|
import type {Logger} from '@fluxer/logger/src/Logger';
|
||||||
@ -31,46 +30,15 @@ interface RegisterAppProxyRoutesOptions {
|
|||||||
assetsPath: string;
|
assetsPath: string;
|
||||||
cspDirectives?: CSPOptions;
|
cspDirectives?: CSPOptions;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
sentryProxy: AppProxySentryProxyConfig | null;
|
|
||||||
sentryProxyPath: string;
|
|
||||||
sentryProxyRouteEnabled: boolean;
|
|
||||||
staticCDNEndpoint: string | undefined;
|
staticCDNEndpoint: string | undefined;
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAppProxyRoutes(options: RegisterAppProxyRoutesOptions): void {
|
export function registerAppProxyRoutes(options: RegisterAppProxyRoutesOptions): void {
|
||||||
const {
|
const {app, assetsPath, cspDirectives, logger, staticCDNEndpoint, staticDir} = options;
|
||||||
app,
|
|
||||||
assetsPath,
|
|
||||||
cspDirectives,
|
|
||||||
logger,
|
|
||||||
sentryProxy,
|
|
||||||
sentryProxyPath,
|
|
||||||
sentryProxyRouteEnabled,
|
|
||||||
staticCDNEndpoint,
|
|
||||||
staticDir,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
app.get('/_health', (c) => c.text('OK'));
|
app.get('/_health', (c) => c.text('OK'));
|
||||||
|
|
||||||
app.all(sentryProxyPath, (c) =>
|
|
||||||
proxySentry(c, {
|
|
||||||
enabled: sentryProxyRouteEnabled,
|
|
||||||
logger,
|
|
||||||
sentryProxy,
|
|
||||||
sentryProxyPath,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.all(`${sentryProxyPath}/*`, (c) =>
|
|
||||||
proxySentry(c, {
|
|
||||||
enabled: sentryProxyRouteEnabled,
|
|
||||||
logger,
|
|
||||||
sentryProxy,
|
|
||||||
sentryProxyPath,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (staticCDNEndpoint) {
|
if (staticCDNEndpoint) {
|
||||||
app.get(`${assetsPath}/*`, (c) =>
|
app.get(`${assetsPath}/*`, (c) =>
|
||||||
proxyAssets(c, {
|
proxyAssets(c, {
|
||||||
|
|||||||
@ -1,72 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Fluxer Contributors
|
|
||||||
*
|
|
||||||
* This file is part of Fluxer.
|
|
||||||
*
|
|
||||||
* Fluxer is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Fluxer is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
|
||||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
|
||||||
import type {Logger} from '@fluxer/logger/src/Logger';
|
|
||||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
|
||||||
import {createRateLimitMiddleware} from '@fluxer/rate_limit/src/middleware/RateLimitMiddleware';
|
|
||||||
|
|
||||||
interface ProxyRateLimitConfig {
|
|
||||||
enabled?: boolean;
|
|
||||||
limit: number;
|
|
||||||
windowMs: number;
|
|
||||||
skipPaths?: Array<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateProxyRateLimitMiddlewareOptions {
|
|
||||||
rateLimitService: IRateLimitService | null;
|
|
||||||
bucketPrefix: string;
|
|
||||||
config: ProxyRateLimitConfig;
|
|
||||||
logger: Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProxyRateLimitMiddleware(options: CreateProxyRateLimitMiddlewareOptions) {
|
|
||||||
const {bucketPrefix, config, logger, rateLimitService} = options;
|
|
||||||
|
|
||||||
return createRateLimitMiddleware({
|
|
||||||
rateLimitService: () => rateLimitService,
|
|
||||||
config: {
|
|
||||||
get enabled() {
|
|
||||||
const hasRateLimitService = Boolean(rateLimitService);
|
|
||||||
return config.enabled !== undefined ? hasRateLimitService && config.enabled : hasRateLimitService;
|
|
||||||
},
|
|
||||||
limit: config.limit,
|
|
||||||
windowMs: config.windowMs,
|
|
||||||
skipPaths: config.skipPaths,
|
|
||||||
},
|
|
||||||
getClientIdentifier: (req) => {
|
|
||||||
const realIp = extractClientIp(req);
|
|
||||||
return realIp || 'unknown';
|
|
||||||
},
|
|
||||||
getBucketName: (identifier, _ctx) => `${bucketPrefix}:ip:${identifier}`,
|
|
||||||
onRateLimitExceeded: (c, retryAfter) => {
|
|
||||||
const identifier = extractClientIp(c.req.raw) || 'unknown';
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
ip: identifier,
|
|
||||||
path: c.req.path,
|
|
||||||
retryAfter,
|
|
||||||
},
|
|
||||||
'proxy rate limit exceeded',
|
|
||||||
);
|
|
||||||
return c.text('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Fluxer Contributors
|
|
||||||
*
|
|
||||||
* This file is part of Fluxer.
|
|
||||||
*
|
|
||||||
* Fluxer is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Fluxer is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {normalizeHost} from '@fluxer/app_proxy/src/app_proxy/utils/Host';
|
|
||||||
import type {Context, Next} from 'hono';
|
|
||||||
|
|
||||||
interface SentryHostProxyMiddlewareOptions {
|
|
||||||
sentryHost: string;
|
|
||||||
// biome-ignore lint/suspicious/noConfusingVoidType: hono middleware may return void
|
|
||||||
rateLimitMiddleware: (c: Context, next: Next) => Promise<Response | undefined | void>;
|
|
||||||
proxyHandler: (c: Context) => Promise<Response>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSentryHostProxyMiddleware(options: SentryHostProxyMiddlewareOptions) {
|
|
||||||
const {proxyHandler, rateLimitMiddleware, sentryHost} = options;
|
|
||||||
|
|
||||||
return async function sentryHostProxy(c: Context, next: Next): Promise<Response | undefined> {
|
|
||||||
const incomingHost = normalizeHost(c.req.raw.headers.get('host') ?? '');
|
|
||||||
if (incomingHost && incomingHost === sentryHost) {
|
|
||||||
const result = await rateLimitMiddleware(c, async () => {
|
|
||||||
c.res = await proxyHandler(c);
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return c.res;
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Fluxer Contributors
|
|
||||||
*
|
|
||||||
* This file is part of Fluxer.
|
|
||||||
*
|
|
||||||
* Fluxer is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Fluxer is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {AppProxySentryProxyConfig} from '@fluxer/app_proxy/src/AppProxyTypes';
|
|
||||||
import {createProxyRequestHeaders, forwardProxyRequest} from '@fluxer/app_proxy/src/app_proxy/proxy/ProxyRequest';
|
|
||||||
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
|
|
||||||
import type {Logger} from '@fluxer/logger/src/Logger';
|
|
||||||
import type {Context} from 'hono';
|
|
||||||
|
|
||||||
export interface ProxySentryOptions {
|
|
||||||
enabled: boolean;
|
|
||||||
sentryProxy: AppProxySentryProxyConfig | null;
|
|
||||||
sentryProxyPath: string;
|
|
||||||
logger: Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function proxySentry(c: Context, options: ProxySentryOptions): Promise<Response> {
|
|
||||||
const {enabled, logger, sentryProxy, sentryProxyPath} = options;
|
|
||||||
if (!enabled || !sentryProxy) {
|
|
||||||
return c.text('Sentry proxy not configured', HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = resolveSentryTargetPath(c.req.path, sentryProxyPath, sentryProxy.path_prefix);
|
|
||||||
const targetUrl = new URL(targetPath, sentryProxy.target_url);
|
|
||||||
const upstreamSentryEndpoint = new URL(sentryProxy.target_url);
|
|
||||||
const headers = createProxyRequestHeaders({
|
|
||||||
incomingHeaders: c.req.raw.headers,
|
|
||||||
upstreamHost: upstreamSentryEndpoint.host,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await forwardProxyRequest({
|
|
||||||
targetUrl,
|
|
||||||
method: c.req.method,
|
|
||||||
headers,
|
|
||||||
body: c.req.raw.body,
|
|
||||||
bufferResponseBody: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
path: c.req.path,
|
|
||||||
targetUrl: targetUrl.toString(),
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
'sentry proxy error',
|
|
||||||
);
|
|
||||||
return c.text('Bad Gateway', HttpStatus.BAD_GATEWAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSentryTargetPath(requestPath: string, sentryProxyPath: string, sentryPathPrefix: string): string {
|
|
||||||
let normalizedPath = requestPath;
|
|
||||||
if (normalizedPath.startsWith(sentryProxyPath)) {
|
|
||||||
normalizedPath = normalizedPath.slice(sentryProxyPath.length) || '/';
|
|
||||||
}
|
|
||||||
if (!normalizedPath.startsWith('/')) {
|
|
||||||
normalizedPath = `/${normalizedPath}`;
|
|
||||||
}
|
|
||||||
return `${sentryPathPrefix}${normalizedPath}`;
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Fluxer Contributors
|
|
||||||
*
|
|
||||||
* This file is part of Fluxer.
|
|
||||||
*
|
|
||||||
* Fluxer is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* Fluxer is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function normalizeHost(value: string): string {
|
|
||||||
const trimmed = value.trim().toLowerCase();
|
|
||||||
if (!trimmed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const primary = trimmed.split(',')[0]?.trim() ?? '';
|
|
||||||
if (primary.startsWith('[')) {
|
|
||||||
const end = primary.indexOf(']');
|
|
||||||
return end > 0 ? primary.slice(1, end) : primary;
|
|
||||||
}
|
|
||||||
return primary.split(':')[0] ?? primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSentryHost(config: {
|
|
||||||
sentry_proxy: {target_url: string} | null;
|
|
||||||
sentry_report_host: string;
|
|
||||||
}): string | null {
|
|
||||||
if (!config.sentry_proxy || !config.sentry_report_host) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedSentryHost = normalizeHostValue(config.sentry_report_host);
|
|
||||||
return normalizedSentryHost || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHostValue(rawHost: string): string {
|
|
||||||
try {
|
|
||||||
return normalizeHost(new URL(rawHost).host);
|
|
||||||
} catch {
|
|
||||||
return normalizeHost(rawHost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {randomBytes} from 'node:crypto';
|
import {randomBytes} from 'node:crypto';
|
||||||
import type {SentryDSN} from '@fluxer/app_proxy/src/app_server/utils/SentryDSN';
|
import {parseSentryDSN} from '@fluxer/app_proxy/src/app_server/utils/SentryDSN';
|
||||||
|
|
||||||
export const CSP_HOSTS = {
|
export const CSP_HOSTS = {
|
||||||
FRAME: [
|
FRAME: [
|
||||||
@ -71,8 +71,6 @@ export const CSP_HOSTS = {
|
|||||||
'https://*.fluxer.workers.dev',
|
'https://*.fluxer.workers.dev',
|
||||||
'https://fluxerusercontent.com',
|
'https://fluxerusercontent.com',
|
||||||
'https://fluxerstatic.com',
|
'https://fluxerstatic.com',
|
||||||
'https://sentry.web.fluxer.app',
|
|
||||||
'https://sentry.web.canary.fluxer.app',
|
|
||||||
'https://fluxer.media',
|
'https://fluxer.media',
|
||||||
'http://127.0.0.1:21863',
|
'http://127.0.0.1:21863',
|
||||||
'http://127.0.0.1:21864',
|
'http://127.0.0.1:21864',
|
||||||
@ -95,33 +93,26 @@ export interface CSPOptions {
|
|||||||
reportUri?: string;
|
reportUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentryProxyConfig {
|
export interface SentryCSPConfig {
|
||||||
sentryProxy: SentryDSN | null;
|
sentryDsn: string;
|
||||||
sentryProxyPath: string;
|
|
||||||
sentryReportHost: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateNonce(): string {
|
export function generateNonce(): string {
|
||||||
return randomBytes(16).toString('hex');
|
return randomBytes(16).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSentryReportURI(config: SentryProxyConfig): string {
|
export function buildSentryReportURI(config: SentryCSPConfig): string {
|
||||||
const sentry = config.sentryProxy;
|
const sentry = parseSentryDSN(config.sentryDsn);
|
||||||
if (!sentry) {
|
if (!sentry) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathPrefix = config.sentryProxyPath.replace(/\/+$/, '');
|
let uri = `${sentry.targetUrl}${sentry.pathPrefix}/api/${sentry.projectId}/security/?sentry_version=7`;
|
||||||
let uri = `${pathPrefix}/api/${sentry.projectId}/security/?sentry_version=7`;
|
|
||||||
|
|
||||||
if (sentry.publicKey) {
|
if (sentry.publicKey) {
|
||||||
uri += `&sentry_key=${sentry.publicKey}`;
|
uri += `&sentry_key=${sentry.publicKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.sentryReportHost) {
|
|
||||||
return config.sentryReportHost + uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
return uri;
|
return uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,8 +151,13 @@ export function buildCSP(nonce: string, options?: CSPOptions): string {
|
|||||||
return directives.join('; ');
|
return directives.join('; ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
|
export function buildFluxerCSPOptions(config: SentryCSPConfig): CSPOptions {
|
||||||
const reportURI = buildSentryReportURI(config);
|
const reportURI = buildSentryReportURI(config);
|
||||||
|
const sentry = parseSentryDSN(config.sentryDsn);
|
||||||
|
const connectSrc: Array<string> = [...CSP_HOSTS.CONNECT];
|
||||||
|
if (sentry) {
|
||||||
|
connectSrc.push(sentry.targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scriptSrc: [...CSP_HOSTS.SCRIPT],
|
scriptSrc: [...CSP_HOSTS.SCRIPT],
|
||||||
@ -169,7 +165,7 @@ export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
|
|||||||
imgSrc: [...CSP_HOSTS.IMAGE],
|
imgSrc: [...CSP_HOSTS.IMAGE],
|
||||||
mediaSrc: [...CSP_HOSTS.MEDIA],
|
mediaSrc: [...CSP_HOSTS.MEDIA],
|
||||||
fontSrc: [...CSP_HOSTS.FONT],
|
fontSrc: [...CSP_HOSTS.FONT],
|
||||||
connectSrc: [...CSP_HOSTS.CONNECT],
|
connectSrc: Array.from(new Set(connectSrc)),
|
||||||
frameSrc: [...CSP_HOSTS.FRAME],
|
frameSrc: [...CSP_HOSTS.FRAME],
|
||||||
workerSrc: [...CSP_HOSTS.WORKER],
|
workerSrc: [...CSP_HOSTS.WORKER],
|
||||||
manifestSrc: [...CSP_HOSTS.MANIFEST],
|
manifestSrc: [...CSP_HOSTS.MANIFEST],
|
||||||
@ -177,6 +173,6 @@ export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFluxerCSP(nonce: string, config: SentryProxyConfig): string {
|
export function buildFluxerCSP(nonce: string, config: SentryCSPConfig): string {
|
||||||
return buildCSP(nonce, buildFluxerCSPOptions(config));
|
return buildCSP(nonce, buildFluxerCSPOptions(config));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1587,58 +1587,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app_proxy_kv": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Valkey/Redis settings for the App Proxy.",
|
|
||||||
"required": [
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Full URL to Valkey/Redis."
|
|
||||||
},
|
|
||||||
"timeout_ms": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Request timeout for Valkey/Redis in milliseconds.",
|
|
||||||
"default": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_proxy_sentry_rate_limit": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Rate limiting for Sentry error reporting requests.",
|
|
||||||
"properties": {
|
|
||||||
"limit": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Number of Sentry requests allowed per window.",
|
|
||||||
"default": 100
|
|
||||||
},
|
|
||||||
"window_ms": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Time window for Sentry rate limiting in milliseconds.",
|
|
||||||
"default": 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_proxy_rate_limit": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Rate limit settings for the App Proxy.",
|
|
||||||
"properties": {
|
|
||||||
"sentry": {
|
|
||||||
"description": "Sentry reporting rate limit configuration.",
|
|
||||||
"$ref": "#/$defs/app_proxy_sentry_rate_limit",
|
|
||||||
"default": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_proxy_service": {
|
"app_proxy_service": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Configuration for the App Proxy service (frontend server).",
|
"description": "Configuration for the App Proxy service (frontend server).",
|
||||||
"required": [
|
|
||||||
"sentry_report_host",
|
|
||||||
"sentry_dsn"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"port": {
|
"port": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@ -1650,32 +1601,10 @@
|
|||||||
"description": "URL endpoint for serving static assets via CDN.",
|
"description": "URL endpoint for serving static assets via CDN.",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
"sentry_proxy_path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "URL path for proxying Sentry requests.",
|
|
||||||
"default": "/error-reporting-proxy"
|
|
||||||
},
|
|
||||||
"sentry_report_host": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Hostname to which Sentry reports should be sent."
|
|
||||||
},
|
|
||||||
"sentry_dsn": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Sentry DSN (Data Source Name) for frontend error tracking."
|
|
||||||
},
|
|
||||||
"assets_dir": {
|
"assets_dir": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Filesystem directory containing static assets.",
|
"description": "Filesystem directory containing static assets.",
|
||||||
"default": "./assets"
|
"default": "./assets"
|
||||||
},
|
|
||||||
"kv": {
|
|
||||||
"description": "Valkey/Redis configuration for the proxy.",
|
|
||||||
"$ref": "#/$defs/app_proxy_kv"
|
|
||||||
},
|
|
||||||
"rate_limit": {
|
|
||||||
"description": "Rate limiting configuration for the App Proxy.",
|
|
||||||
"$ref": "#/$defs/app_proxy_rate_limit",
|
|
||||||
"default": {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -2120,26 +2049,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Frontend Sentry DSN."
|
"description": "Frontend Sentry DSN."
|
||||||
},
|
|
||||||
"sentry_proxy_path": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "/error-reporting-proxy",
|
|
||||||
"description": "Path to proxy Sentry requests."
|
|
||||||
},
|
|
||||||
"sentry_report_host": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Host for Sentry reporting."
|
|
||||||
},
|
|
||||||
"sentry_project_id": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Sentry Project ID."
|
|
||||||
},
|
|
||||||
"sentry_public_key": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Sentry Public Key."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ function makeMinimalConfig(overrides: Record<string, unknown> = {}): Record<stri
|
|||||||
secret_key_base: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
secret_key_base: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||||
oauth_client_secret: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
oauth_client_secret: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
||||||
},
|
},
|
||||||
app_proxy: {port: 8773, sentry_report_host: 'sentry.io', sentry_dsn: 'https://test@sentry.io/1'},
|
app_proxy: {port: 8773},
|
||||||
marketing: {
|
marketing: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
port: 8774,
|
port: 8774,
|
||||||
|
|||||||
@ -1,55 +1,8 @@
|
|||||||
{
|
{
|
||||||
"app_proxy_kv": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Valkey/Redis settings for the App Proxy.",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["url"],
|
|
||||||
"properties": {
|
|
||||||
"url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Full URL to Valkey/Redis."
|
|
||||||
},
|
|
||||||
"timeout_ms": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Request timeout for Valkey/Redis in milliseconds.",
|
|
||||||
"default": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_proxy_sentry_rate_limit": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Rate limiting for Sentry error reporting requests.",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"limit": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Number of Sentry requests allowed per window.",
|
|
||||||
"default": 100
|
|
||||||
},
|
|
||||||
"window_ms": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Time window for Sentry rate limiting in milliseconds.",
|
|
||||||
"default": 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_proxy_rate_limit": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Rate limit settings for the App Proxy.",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"sentry": {
|
|
||||||
"description": "Sentry reporting rate limit configuration.",
|
|
||||||
"$ref": "#/$defs/app_proxy_sentry_rate_limit",
|
|
||||||
"default": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_proxy_service": {
|
"app_proxy_service": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Configuration for the App Proxy service (frontend server).",
|
"description": "Configuration for the App Proxy service (frontend server).",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["sentry_report_host", "sentry_dsn"],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"port": {
|
"port": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@ -61,32 +14,10 @@
|
|||||||
"description": "URL endpoint for serving static assets via CDN.",
|
"description": "URL endpoint for serving static assets via CDN.",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
"sentry_proxy_path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "URL path for proxying Sentry requests.",
|
|
||||||
"default": "/error-reporting-proxy"
|
|
||||||
},
|
|
||||||
"sentry_report_host": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Hostname to which Sentry reports should be sent."
|
|
||||||
},
|
|
||||||
"sentry_dsn": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Sentry DSN (Data Source Name) for frontend error tracking."
|
|
||||||
},
|
|
||||||
"assets_dir": {
|
"assets_dir": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Filesystem directory containing static assets.",
|
"description": "Filesystem directory containing static assets.",
|
||||||
"default": "./assets"
|
"default": "./assets"
|
||||||
},
|
|
||||||
"kv": {
|
|
||||||
"description": "Valkey/Redis configuration for the proxy.",
|
|
||||||
"$ref": "#/$defs/app_proxy_kv"
|
|
||||||
},
|
|
||||||
"rate_limit": {
|
|
||||||
"description": "Rate limiting configuration for the App Proxy.",
|
|
||||||
"$ref": "#/$defs/app_proxy_rate_limit",
|
|
||||||
"default": {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,26 +111,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Frontend Sentry DSN."
|
"description": "Frontend Sentry DSN."
|
||||||
},
|
|
||||||
"sentry_proxy_path": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "/error-reporting-proxy",
|
|
||||||
"description": "Path to proxy Sentry requests."
|
|
||||||
},
|
|
||||||
"sentry_report_host": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Host for Sentry reporting."
|
|
||||||
},
|
|
||||||
"sentry_project_id": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Sentry Project ID."
|
|
||||||
},
|
|
||||||
"sentry_public_key": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "",
|
|
||||||
"description": "Sentry Public Key."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -219,6 +219,37 @@ describe('Fluxer Markdown Parser', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('timestamp at max js date boundary parses', () => {
|
||||||
|
const input = '<t:8640000000000>';
|
||||||
|
const parser = new Parser(input, 0);
|
||||||
|
const {nodes: ast} = parser.parse();
|
||||||
|
|
||||||
|
expect(ast).toEqual([
|
||||||
|
{
|
||||||
|
type: NodeType.Timestamp,
|
||||||
|
timestamp: 8640000000000,
|
||||||
|
style: TimestampStyle.ShortDateTime,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamp above max js date boundary does not parse', () => {
|
||||||
|
const input = '<t:8640000000001>';
|
||||||
|
const parser = new Parser(input, 0);
|
||||||
|
const {nodes: ast} = parser.parse();
|
||||||
|
|
||||||
|
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamp that overflows to infinity does not parse', () => {
|
||||||
|
const largeDigits = '9'.repeat(400);
|
||||||
|
const input = `<t:${largeDigits}>`;
|
||||||
|
const parser = new Parser(input, 0);
|
||||||
|
const {nodes: ast} = parser.parse();
|
||||||
|
|
||||||
|
expect(ast).toEqual([{type: NodeType.Text, content: input}]);
|
||||||
|
});
|
||||||
|
|
||||||
test('timestamp with leading zeros', () => {
|
test('timestamp with leading zeros', () => {
|
||||||
const input = '<t:0001618953630>';
|
const input = '<t:0001618953630>';
|
||||||
const flags = 0;
|
const flags = 0;
|
||||||
|
|||||||
@ -58,6 +58,15 @@ export function parseTimestamp(text: string): ParserResult | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timestampMillis = timestamp * 1000;
|
||||||
|
if (!Number.isFinite(timestampMillis)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(new Date(timestampMillis).getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let style: TimestampStyle;
|
let style: TimestampStyle;
|
||||||
if (stylePart !== undefined) {
|
if (stylePart !== undefined) {
|
||||||
if (stylePart === '') {
|
if (stylePart === '') {
|
||||||
|
|||||||
@ -169,6 +169,10 @@ We work with trusted third-party service providers who process data on our behal
|
|||||||
- **hCaptcha** – backup CAPTCHA provider; users can choose hCaptcha instead of Cloudflare Turnstile for bot prevention challenges.
|
- **hCaptcha** – backup CAPTCHA provider; users can choose hCaptcha instead of Cloudflare Turnstile for bot prevention challenges.
|
||||||
- **Arachnid Shield API** – CSAM scanning for user-uploaded media, operated by the Canadian Centre for Child Protection (C3P), as described in [Section 5](#5-content-scanning-for-safety).
|
- **Arachnid Shield API** – CSAM scanning for user-uploaded media, operated by the Canadian Centre for Child Protection (C3P), as described in [Section 5](#5-content-scanning-for-safety).
|
||||||
|
|
||||||
|
#### Observability and error reporting
|
||||||
|
|
||||||
|
- **Sentry** – application error monitoring. We send error and crash diagnostics directly to Sentry over HTTPS so we can investigate reliability and security issues. This may include stack traces, runtime metadata (for example browser, OS, and device details), release/build identifiers, and account identifiers associated with the active session (for example user ID, username, and email). We do not use this data for advertising, and routine error reports do not include private message content or uploaded file attachments.
|
||||||
|
|
||||||
#### Third-Party Content
|
#### Third-Party Content
|
||||||
|
|
||||||
- **Google** – YouTube embeds.
|
- **Google** – YouTube embeds.
|
||||||
|
|||||||
@ -45,10 +45,6 @@ export type LimitConfigResponse = z.infer<typeof LimitConfigResponse>;
|
|||||||
|
|
||||||
export const AppPublicConfigResponse = z.object({
|
export const AppPublicConfigResponse = z.object({
|
||||||
sentry_dsn: z.string().describe('Sentry DSN for client-side error reporting'),
|
sentry_dsn: z.string().describe('Sentry DSN for client-side error reporting'),
|
||||||
sentry_proxy_path: z.string().describe('Proxy path for Sentry requests'),
|
|
||||||
sentry_report_host: z.string().describe('Host for Sentry error reports'),
|
|
||||||
sentry_project_id: z.string().describe('Sentry project ID'),
|
|
||||||
sentry_public_key: z.string().describe('Sentry public key'),
|
|
||||||
});
|
});
|
||||||
export type AppPublicConfigResponse = z.infer<typeof AppPublicConfigResponse>;
|
export type AppPublicConfigResponse = z.infer<typeof AppPublicConfigResponse>;
|
||||||
|
|
||||||
|
|||||||
@ -91,7 +91,7 @@ if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
|
|||||||
else
|
else
|
||||||
CONFIG_PATH="/etc/fluxer/config.stable.json"
|
CONFIG_PATH="/etc/fluxer/config.stable.json"
|
||||||
fi
|
fi
|
||||||
read -r CADDY_APP_DOMAIN SENTRY_CADDY_DOMAIN <<EOF
|
read -r CADDY_APP_DOMAIN <<EOF
|
||||||
$(python3 - <<'PY' "${CONFIG_PATH}"
|
$(python3 - <<'PY' "${CONFIG_PATH}"
|
||||||
import sys, json
|
import sys, json
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@ -122,12 +122,7 @@ derived_app = build_url(public_scheme, derive_domain('app'), public_port)
|
|||||||
app_url = (overrides.get('app') or derived_app).strip()
|
app_url = (overrides.get('app') or derived_app).strip()
|
||||||
parsed_app = urlparse(app_url)
|
parsed_app = urlparse(app_url)
|
||||||
app_host = parsed_app.netloc or parsed_app.path
|
app_host = parsed_app.netloc or parsed_app.path
|
||||||
sentry_host_raw = (cfg.get('services', {}).get('app_proxy', {}).get('sentry_report_host') or '').strip()
|
print(app_host)
|
||||||
if sentry_host_raw and not sentry_host_raw.startswith('http'):
|
|
||||||
sentry_host_raw = f"https://{sentry_host_raw}"
|
|
||||||
|
|
||||||
sentry_host = urlparse(sentry_host_raw).netloc if sentry_host_raw else ''
|
|
||||||
print(f"{app_host} {sentry_host}")
|
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
EOF
|
EOF
|
||||||
@ -136,17 +131,6 @@ if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
|
|||||||
else
|
else
|
||||||
API_TARGET="fluxer-api_app"
|
API_TARGET="fluxer-api_app"
|
||||||
fi
|
fi
|
||||||
SENTRY_REPORT_HOST="$(
|
|
||||||
python3 - <<'PY' "${CONFIG_PATH}"
|
|
||||||
import sys, json
|
|
||||||
path = sys.argv[1]
|
|
||||||
with open(path, 'r') as f:
|
|
||||||
cfg = json.load(f)
|
|
||||||
app_proxy = cfg.get('services', {}).get('app_proxy', {})
|
|
||||||
host = (app_proxy.get('sentry_report_host') or '').rstrip('/')
|
|
||||||
print(host)
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
sudo mkdir -p "/opt/${SERVICE_NAME}"
|
sudo mkdir -p "/opt/${SERVICE_NAME}"
|
||||||
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
|
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
|
||||||
cd "/opt/${SERVICE_NAME}"
|
cd "/opt/${SERVICE_NAME}"
|
||||||
@ -171,7 +155,6 @@ x-common-caddy-headers: &common_caddy_headers
|
|||||||
caddy.header.X-Content-Type-Options: "nosniff"
|
caddy.header.X-Content-Type-Options: "nosniff"
|
||||||
caddy.header.Referrer-Policy: "strict-origin-when-cross-origin"
|
caddy.header.Referrer-Policy: "strict-origin-when-cross-origin"
|
||||||
caddy.header.X-Frame-Options: "DENY"
|
caddy.header.X-Frame-Options: "DENY"
|
||||||
caddy.header.Expect-Ct: "max-age=86400, report-uri=\\"${SENTRY_REPORT_HOST}/api/4510205815291904/security/?sentry_key=59ced0e2666ab83dd1ddb056cdd22d1b\\""
|
|
||||||
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate"
|
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate"
|
||||||
caddy.header.Pragma: "no-cache"
|
caddy.header.Pragma: "no-cache"
|
||||||
caddy.header.Expires: "0"
|
caddy.header.Expires: "0"
|
||||||
@ -206,22 +189,6 @@ services:
|
|||||||
networks: [fluxer-shared]
|
networks: [fluxer-shared]
|
||||||
healthcheck: *healthcheck
|
healthcheck: *healthcheck
|
||||||
|
|
||||||
sentry:
|
|
||||||
image: ${IMAGE_TAG}
|
|
||||||
volumes:
|
|
||||||
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
|
|
||||||
deploy:
|
|
||||||
<<: *deploy_base
|
|
||||||
replicas: 1
|
|
||||||
labels:
|
|
||||||
<<: *common_caddy_headers
|
|
||||||
caddy: ${SENTRY_CADDY_DOMAIN}
|
|
||||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
|
||||||
environment:
|
|
||||||
<<: *env_base
|
|
||||||
networks: [fluxer-shared]
|
|
||||||
healthcheck: *healthcheck
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fluxer-shared:
|
fluxer-shared:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user