fix: various fixes to things + simply app proxy sentry setup

This commit is contained in:
Hampus Kraft 2026-02-19 00:29:58 +00:00
parent ff1d15f7aa
commit 528e4e0d7f
No known key found for this signature in database
GPG Key ID: 6090864C465A454D
44 changed files with 441 additions and 1042 deletions

View File

@ -170,22 +170,6 @@ function stripApiSuffix(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) {
const appPublic = getValue(config, ['app_public'], {});
const domain = getValue(config, ['domain'], {});
@ -193,25 +177,13 @@ function resolveAppPublic(config) {
const endpoints = deriveEndpointsFromDomain(domain, overrides);
const defaultBootstrapEndpoint = endpoints.api;
const defaultPublicEndpoint = stripApiSuffix(endpoints.api);
const sentryDsn =
asString(appPublic.sentry_dsn) ?? asString(getValue(config, ['services', 'app_proxy', 'sentry_dsn']));
const sentryParsed = parseSentryDsn(sentryDsn);
const sentryDsn = asString(appPublic.sentry_dsn);
return {
apiVersion: asString(appPublic.api_version, '1'),
bootstrapApiEndpoint: asString(appPublic.bootstrap_api_endpoint, defaultBootstrapEndpoint),
bootstrapApiPublicEndpoint: asString(appPublic.bootstrap_api_public_endpoint, defaultPublicEndpoint),
relayDirectoryUrl: asString(appPublic.relay_directory_url),
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_RELEASE_CHANNEL: buildMetadata.releaseChannel,
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_BOOTSTRAP_API_ENDPOINT: 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_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
'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_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(

View File

@ -189,7 +189,13 @@ export const PreloadableUserPopout = React.forwardRef<
<Popout
ref={ref}
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}
tooltip={tooltip}

View File

@ -129,6 +129,10 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
);
const [profile, setProfile] = useState<ProfileRecord | null>(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(() => {
setProfile(initialProfile);
@ -136,7 +140,7 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
}, [initialProfile]);
useEffect(() => {
if (!userId || profile) {
if (!userId || activeProfile) {
setIsProfileLoading(false);
return;
}
@ -164,7 +168,7 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
return () => {
cancelled = true;
};
}, [userId, guildId, profile]);
}, [userId, guildId, activeProfile]);
useEffect(() => {
if (!guildId || !userId) {
@ -190,22 +194,24 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob
return null;
}
const effectiveProfile: ProfileRecord | null = profile ?? mockProfile ?? fallbackProfile;
const effectiveProfile: ProfileRecord | null = activeProfile ?? mockProfile ?? fallbackProfile;
const resolvedProfile: ProfileRecord = effectiveProfile ?? fallbackProfile!;
const userNote = userId ? UserNoteStore.getUserNote(userId) : null;
const handleClose = () => {
store.close();
};
const profileIdentityKey = `${displayUser.id}:${guildId ?? 'global'}`;
return (
<UserProfileMobileSheetContent
key={profileIdentityKey}
user={displayUser}
profile={resolvedProfile}
userNote={userNote}
guildId={guildId}
autoFocusNote={autoFocusNote}
isLoading={isProfileLoading}
isLoading={shouldShowProfileLoading}
onClose={handleClose}
/>
);

View File

@ -112,32 +112,53 @@ export const UserProfilePopout: React.FC<UserProfilePopoutProps> = observer(
[isWebhook, user.id, guildId, popoutKey],
);
const fetchProfile = useCallback(async () => {
if (isWebhook) return;
useEffect(() => {
let cancelled = false;
const cachedProfile = UserProfileStore.getProfile(user.id, guildId);
setProfile(cachedProfile ?? createMockProfile(user));
setProfileLoadError(false);
if (isWebhook) {
return () => {
cancelled = true;
};
}
const fetchProfile = async () => {
const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : false;
if (DeveloperOptionsStore.slowProfileLoad) {
await new Promise((resolve) => setTimeout(resolve, 3000));
}
setProfileLoadError(false);
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 cachedProfile = UserProfileStore.getProfile(user.id, guildId);
setProfile(cachedProfile ?? createMockProfile(user));
const nextCachedProfile = UserProfileStore.getProfile(user.id, guildId);
setProfile(nextCachedProfile ?? createMockProfile(user));
setProfileLoadError(true);
}
}, [guildId, user.id, isWebhook]);
};
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
return () => {
cancelled = true;
};
}, [guildId, isWebhook, user]);
useEffect(() => {
if (profileLoadError && profile) {

View File

@ -248,7 +248,13 @@ const SpectatorRow = observer(function SpectatorRow({
return (
<Popout
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"
onOpen={onPopoutOpen}

View File

@ -157,7 +157,13 @@ function VoiceParticipantPopoutRow({entry, guildId, channelId}: VoiceParticipant
return (
<Popout
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"
>

View File

@ -70,8 +70,6 @@ const logger = new Logger('index');
preloadClientInfo();
const normalizePathSegment = (value: string): string => value.replace(/^\/+|\/+$/g, '');
async function resumePendingDesktopHandoffLogin(): Promise<void> {
const electronApi = getElectronAPI();
if (!electronApi || typeof electronApi.consumeDesktopHandoffCode !== 'function') {
@ -103,7 +101,7 @@ async function resumePendingDesktopHandoffLogin(): Promise<void> {
}
function initSentry(): void {
const resolvedSentryDsn = buildRuntimeSentryDsn() || RuntimeConfigStore.sentryDsn;
const resolvedSentryDsn = RuntimeConfigStore.sentryDsn;
const normalizedBuildSha =
Config.PUBLIC_BUILD_SHA && Config.PUBLIC_BUILD_SHA !== 'dev' ? Config.PUBLIC_BUILD_SHA : undefined;
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> {
await initI18n();

View File

@ -310,10 +310,6 @@ class AccountStorage {
sso: instance.sso,
publicPushVapidKey: instance.publicPushVapidKey,
sentryDsn: instance.sentryDsn ?? '',
sentryProxyPath: instance.sentryProxyPath ?? '',
sentryReportHost: instance.sentryReportHost ?? '',
sentryProjectId: instance.sentryProjectId ?? '',
sentryPublicKey: instance.sentryPublicKey ?? '',
limits:
instance.limits !== undefined && instance.limits !== null
? JSON.parse(JSON.stringify(instance.limits))

View File

@ -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"');
});
});

View File

@ -20,6 +20,7 @@
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import type {RendererProps} from '@app/lib/markdown/renderers/RendererTypes';
import {formatTimestamp} from '@app/lib/markdown/utils/DateFormatter';
import {getDateFromUnixTimestampSeconds} from '@app/lib/markdown/utils/TimestampValidation';
import WindowStore from '@app/stores/WindowStore';
import markupStyles from '@app/styles/Markup.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 {DateTime} from 'luxon';
import {observer} from 'mobx-react-lite';
import type {ReactElement} from 'react';
import {useEffect, useState} from 'react';
import React, {type ReactElement, useEffect, useState} from 'react';
export const TimestampRenderer = observer(function TimestampRenderer({
node,
@ -43,25 +43,26 @@ export const TimestampRenderer = observer(function TimestampRenderer({
const {timestamp, style} = node;
const i18n = options.i18n;
const totalMillis = timestamp * 1000;
const date = new Date(totalMillis);
const date = getDateFromUnixTimestampSeconds(timestamp);
const isValidTimestamp = date !== null;
const now = new Date();
const isPast = date < now;
const isFuture = date > now;
const isTodayDate = isSameDay(date);
const isPast = date !== null && date < now;
const isFuture = date !== null && date > now;
const isTodayDate = date !== null && isSameDay(date);
const locale = getCurrentLocale();
const fullDateTime = getFormattedDateTimeWithSeconds(date, locale);
const fullDateTime = date !== null ? getFormattedDateTimeWithSeconds(date, locale) : null;
const isRelativeStyle = style === TimestampStyle.RelativeTime;
const isWindowFocused = WindowStore.focused;
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() => formatTimestamp(timestamp, style, i18n));
const luxonDate = DateTime.fromMillis(totalMillis);
const relativeTime = luxonDate.toRelative();
const [relativeDisplayTime, setRelativeDisplayTime] = useState(() =>
isValidTimestamp ? formatTimestamp(timestamp, style, i18n) : '',
);
const relativeTime = date !== null ? DateTime.fromJSDate(date).toRelative() : null;
useEffect(() => {
if (!isRelativeStyle || !isWindowFocused) {
if (!isValidTimestamp || !isRelativeStyle || !isWindowFocused) {
return;
}
@ -75,7 +76,11 @@ export const TimestampRenderer = observer(function TimestampRenderer({
refreshDisplay();
const intervalId = setInterval(refreshDisplay, 1000);
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 = (
<div className={timestampRendererStyles.tooltipContainer}>

View 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);
});
});

View File

@ -17,6 +17,7 @@
* 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 {getCurrentLocale} from '@app/utils/LocaleUtils';
import {
@ -33,9 +34,8 @@ import {TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
function formatRelativeTime(timestamp: number, i18n: I18n): string {
function formatRelativeTime(date: Date, i18n: I18n): string {
const locale = getCurrentLocale();
const date = new Date(timestamp * 1000);
const now = new Date();
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 {
const locale = getCurrentLocale();
const hour12 = shouldUse12HourFormat(locale);
const date = getDateFromUnixTimestampSeconds(timestamp);
if (date == null) {
return String(timestamp);
}
if (style === TimestampStyle.RelativeTime) {
return formatRelativeTime(timestamp, i18n);
return formatRelativeTime(date, i18n);
}
return formatTimestampWithStyle(timestamp, style, locale, hour12);

View File

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

View 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;
}

View File

@ -243,10 +243,6 @@ class InstanceConfigStore {
push: {public_vapid_key: RuntimeConfigStore.publicPushVapidKey},
appPublic: {
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,
publicKey: null,

View File

@ -69,10 +69,6 @@ export interface InstancePush {
export interface InstanceAppPublic {
sentry_dsn: string;
sentry_proxy_path: string;
sentry_report_host: string;
sentry_project_id: string;
sentry_public_key: string;
}
export type GifProvider = 'klipy' | 'tenor';
@ -110,10 +106,6 @@ export interface RuntimeConfigSnapshot {
publicPushVapidKey: string | null;
limits: LimitConfigSnapshot;
sentryDsn: string;
sentryProxyPath: string;
sentryReportHost: string;
sentryProjectId: string;
sentryPublicKey: string;
relayDirectoryUrl: string | null;
}
@ -160,10 +152,6 @@ class RuntimeConfigStore {
currentDefaultsHash: string | null = null;
sentryDsn: string = '';
sentryProxyPath: string = '/error-reporting-proxy';
sentryReportHost: string = '';
sentryProjectId: string = '';
sentryPublicKey: string = '';
relayDirectoryUrl: string | null = Config.PUBLIC_RELAY_DIRECTORY_URL;
@ -237,10 +225,6 @@ class RuntimeConfigStore {
'limits',
'currentDefaultsHash',
'sentryDsn',
'sentryProxyPath',
'sentryReportHost',
'sentryProjectId',
'sentryPublicKey',
'relayDirectoryUrl',
]);
@ -302,10 +286,6 @@ class RuntimeConfigStore {
this.currentDefaultsHash = null;
this.sentryDsn = snapshot.sentryDsn;
this.sentryProxyPath = snapshot.sentryProxyPath;
this.sentryReportHost = snapshot.sentryReportHost;
this.sentryProjectId = snapshot.sentryProjectId;
this.sentryPublicKey = snapshot.sentryPublicKey;
this.relayDirectoryUrl = snapshot.relayDirectoryUrl;
}
@ -331,10 +311,6 @@ class RuntimeConfigStore {
publicPushVapidKey: this.publicPushVapidKey,
limits: this.cloneLimits(this.limits),
sentryDsn: this.sentryDsn,
sentryProxyPath: this.sentryProxyPath,
sentryReportHost: this.sentryReportHost,
sentryProjectId: this.sentryProjectId,
sentryPublicKey: this.sentryPublicKey,
relayDirectoryUrl: this.relayDirectoryUrl,
};
}
@ -472,10 +448,6 @@ class RuntimeConfigStore {
if (instance.app_public) {
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;
}
});
}

View File

@ -114,10 +114,6 @@ vi.mock('@app/lib/HttpClient', () => {
limits: {version: 1, traitDefinitions: [], rules: []},
app_public: {
sentry_dsn: '',
sentry_proxy_path: '',
sentry_report_host: '',
sentry_project_id: '',
sentry_public_key: '',
},
};

View File

@ -20,98 +20,19 @@
import {loadConfig} from '@fluxer/config/src/ConfigLoader';
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 appProxy = master.services.app_proxy;
if (!appProxy?.kv) {
throw new Error('Application proxy requires `kv` configuration');
}
if (!appProxy.rate_limit) {
throw new Error('Application proxy requires `rate_limit` configuration');
if (!appProxy) {
throw new Error('Application proxy requires `services.app_proxy` configuration');
}
export const Config = {
...extractBaseServiceConfig(master),
port: appProxy.port,
static_cdn_endpoint: appProxy.static_cdn_endpoint,
sentry_proxy_path: normalizeProxyPath(appProxy.sentry_proxy_path),
sentry_report_host: appProxy.sentry_report_host.replace(/\/+$/, ''),
sentry_proxy: parseSentryDsn(appProxy.sentry_dsn),
sentry_dsn: master.app_public.sentry_dsn,
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;

View File

@ -22,53 +22,22 @@ import {shutdownInstrumentation} from '@app/Instrument';
import {Logger} from '@app/Logger';
import {createAppProxyApp} from '@fluxer/app_proxy/src/App';
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 {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({
serviceName: 'fluxer-app-proxy',
skipPaths: ['/_health'],
});
let rateLimitService: RateLimitService | null = null;
async function main(): Promise<void> {
if (Config.kv.url) {
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 cspDirectives = buildFluxerCSPOptions({sentryDsn: Config.sentry_dsn});
const {app, shutdown} = await createAppProxyApp({
config: Config,
cspDirectives,
logger: Logger,
rateLimitService,
metricsCollector: telemetry.metricsCollector,
sentryProxyPath: Config.sentry_proxy_path,
staticCDNEndpoint: Config.static_cdn_endpoint,
staticDir: Config.assets_dir,
tracing: telemetry.tracing,

View File

@ -60902,30 +60902,10 @@
"sentry_dsn": {
"type": "string",
"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": [
"sentry_dsn",
"sentry_proxy_path",
"sentry_report_host",
"sentry_project_id",
"sentry_public_key"
"sentry_dsn"
],
"description": "Public application configuration for client-side features"
},

View File

@ -35,10 +35,6 @@ Public application configuration for client-side features
| Field | Type | Description |
|-------|------|-------------|
| 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>
@ -154,4 +150,3 @@ Push notification configuration
| Field | Type | Description |
|-------|------|-------------|
| public_vapid_key | ?string | VAPID public key for web push notifications |

View File

@ -18,10 +18,10 @@ description: 'config.json reference for self-hosted Fluxer.'
- [csam](#csam)
- [database](#database)
- [dev](#dev)
- [discovery](#discovery)
- [domain](#domain)
- [endpoint_overrides](#endpoint-overrides)
- [federation](#federation)
- [gateway_connection](#gateway-connection)
- [geoip](#geoip)
- [instance](#instance)
- [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. |
| 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: `{}` |
| discovery? | [discovery](#discovery) | Guild discovery listing configuration. Default: `{}` |
| 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. |
| env | enum&lt;`development`, `production`, `test`&gt; | 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: `{}` |
| gateway | [gateway_connection](#gateway-connection) | Configuration for the real-time Gateway service connection. |
| 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` |
| 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_public_endpoint? | string | Public Bootstrap API endpoint. 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">
```json
@ -128,11 +124,7 @@ Public configuration exposed to the frontend application.
"api_version": 1,
"bootstrap_api_endpoint": "",
"bootstrap_api_public_endpoint": "",
"sentry_dsn": "",
"sentry_project_id": "",
"sentry_proxy_path": "/error-reporting-proxy",
"sentry_public_key": "",
"sentry_report_host": ""
"sentry_dsn": ""
}
```
</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
<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
<a id="geoip"></a>
@ -1162,6 +1154,7 @@ Container for all service-specific configurations.
| gateway | [gateway_service](#gateway-service) | |
| marketing? | [marketing_service](#marketing-service) | |
| media_proxy | [media_proxy_service](#media-proxy-service) | |
| nats? | [nats_services](#nats-services) | Default: `{}` |
| queue? | [queue_service](#queue-service) | Default: `{}` |
| s3? | [s3_service](#s3-service) | Default: `{}` |
| server? | [server_service](#server-service) | Default: `{}` |
@ -1211,6 +1204,28 @@ Rate limiting parameters.
```
</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
JSON path: `services.queue`
@ -1219,37 +1234,26 @@ Configuration for the Job Queue service.
| Property | Type | Description |
|----------|------|-------------|
| command_buffer? | number | Size of the internal command buffer. Default: `1000` |
| concurrency? | number | Number of concurrent worker threads. Default: `2` |
| concurrency? | number | Number of concurrent worker threads. Default: `1` |
| 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` |
| export_timeout? | number | Timeout in milliseconds for data export operations. Default: `30000` |
| host? | string | Network interface to bind to. Default: `0.0.0.0` |
| 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. |
| port? | number | Port to listen on. Default: `8088` |
| secret? | string | Secret for queue API authentication. Default: `""` |
| 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_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">
```json
{
"command_buffer": 1000,
"concurrency": 2,
"concurrency": 1,
"data_dir": "./data/queue",
"default_visibility_timeout_ms": 30000,
"export_timeout": 30000,
"host": "0.0.0.0",
"max_receive_batch": 10,
"port": 8080,
"port": 8088,
"secret": "",
"snapshot_after_ops": 10000,
"snapshot_every_ms": 60000,
"snapshot_zstd_level": 3,
"visibility_timeout_backoff_ms": 1000
"snapshot_zstd_level": 3
}
```
</Expandable>
@ -1366,77 +1370,19 @@ Configuration for the App Proxy service (frontend server).
| Property | Type | Description |
|----------|------|-------------|
| 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` |
| 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: `""` |
<Expandable title="Example JSON">
```json
{
"sentry_dsn": "your_sentry_dsn",
"sentry_report_host": "your_sentry_report_host",
"assets_dir": "./assets",
"port": 8773,
"sentry_proxy_path": "/error-reporting-proxy",
"static_cdn_endpoint": ""
}
```
</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
JSON path: `services.gateway`
@ -1446,7 +1392,6 @@ Configuration for the Gateway service (WebSocket).
| Property | Type | Description |
|----------|------|-------------|
| 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_report_interval_ms? | number | Interval in milliseconds to report gateway metrics. Default: `30000` |
| 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_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` |
| rpc_tcp_port? | number | Port for API-to-Gateway internal RPC over TCP. Default: `8772` |
<Expandable title="Example JSON">
```json
{
"admin_reload_secret": "your_admin_reload_secret",
"api_host": "your_api_host",
"media_proxy_endpoint": "your_media_proxy_endpoint",
"gateway_metrics_enabled": false,
"gateway_metrics_report_interval_ms": 30000,
@ -1487,8 +1430,7 @@ Configuration for the Gateway service (WebSocket).
"push_enabled": true,
"push_subscriptions_cache_mb": 1024,
"push_user_guild_settings_cache_mb": 1024,
"release_node": "fluxer_gateway@gateway",
"rpc_tcp_port": 8772
"release_node": "fluxer_gateway@gateway"
}
```
</Expandable>

View File

@ -184,7 +184,7 @@ export const DiscoveryPage: FC<DiscoveryPageProps> = ({
<TableRow key={app.guild_id}>
<TableCell>
<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"
>
{app.guild_id}

View File

@ -308,10 +308,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
appPublic: {
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: {

View File

@ -96,10 +96,6 @@ export interface APIConfig {
appPublic: {
sentryDsn: string;
sentryProxyPath: string;
sentryReportHost: string;
sentryProjectId: string;
sentryPublicKey: string;
};
email: {

View File

@ -95,10 +95,6 @@ export function InstanceController(app: Hono<HonoEnv>) {
},
app_public: {
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,
},
};

View File

@ -30,15 +30,10 @@ export interface AppProxyResult {
export async function createAppProxyApp(options: CreateAppProxyAppOptions): Promise<AppProxyResult> {
const {
assetsPath = '/assets',
config,
cspDirectives,
customMiddleware = [],
logger,
metricsCollector,
rateLimitService = null,
sentryProxyEnabled = true,
sentryProxyPath = '/sentry',
sentryProxyRouteEnabled = true,
staticCDNEndpoint,
staticDir,
tracing,
@ -48,13 +43,9 @@ export async function createAppProxyApp(options: CreateAppProxyAppOptions): Prom
applyAppProxyMiddleware({
app,
config,
customMiddleware,
logger,
metricsCollector,
rateLimitService,
sentryProxyEnabled,
sentryProxyPath,
tracing,
});
@ -63,9 +54,6 @@ export async function createAppProxyApp(options: CreateAppProxyAppOptions): Prom
assetsPath,
cspDirectives,
logger,
sentryProxy: config.sentry_proxy,
sentryProxyPath,
sentryProxyRouteEnabled,
staticCDNEndpoint,
staticDir,
});

View File

@ -22,34 +22,13 @@ import type {SentryConfig, TelemetryConfig} from '@fluxer/config/src/MasterZodSc
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
import type {Logger} from '@fluxer/logger/src/Logger';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {MiddlewareHandler} from 'hono';
export interface AppProxySentryProxyConfig {
project_id: string;
public_key: string;
target_url: string;
path_prefix: string;
}
export interface AppProxyConfig {
env: string;
port: number;
static_cdn_endpoint: string;
sentry_proxy_path: string;
sentry_report_host: string;
sentry_proxy: AppProxySentryProxyConfig | null;
assets_dir: string;
kv: {
url: string;
timeout_ms: number;
};
rate_limit: {
sentry: {
limit: number;
window_ms: number;
};
};
telemetry: TelemetryConfig;
sentry: SentryConfig;
}
@ -57,7 +36,6 @@ export interface AppProxyConfig {
export interface AppProxyContext {
config: AppProxyConfig;
logger: Logger;
rateLimitService: IRateLimitService | null;
}
export interface AppProxyHonoEnv {
@ -69,15 +47,11 @@ export type AppProxyMiddleware = MiddlewareHandler<AppProxyHonoEnv>;
export interface CreateAppProxyAppOptions {
config: AppProxyConfig;
logger: Logger;
rateLimitService?: IRateLimitService | null;
metricsCollector?: MetricsCollector;
tracing?: TracingOptions;
customMiddleware?: Array<AppProxyMiddleware>;
sentryProxyPath?: string;
assetsPath?: string;
staticCDNEndpoint?: string;
staticDir?: string;
cspDirectives?: CSPOptions;
sentryProxyEnabled?: boolean;
sentryProxyRouteEnabled?: boolean;
}

View File

@ -17,44 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AppProxyConfig, 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 type {AppProxyHonoEnv, AppProxyMiddleware} from '@fluxer/app_proxy/src/AppProxyTypes';
import {isExpectedError} from '@fluxer/app_proxy/src/ErrorClassification';
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
import type {MetricsCollector} from '@fluxer/hono_types/src/MetricsTypes';
import type {TracingOptions} from '@fluxer/hono_types/src/TracingTypes';
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 type {Context, Hono} from 'hono';
interface ApplyAppProxyMiddlewareOptions {
app: Hono<AppProxyHonoEnv>;
config: AppProxyConfig;
customMiddleware: Array<AppProxyMiddleware>;
logger: Logger;
metricsCollector?: MetricsCollector;
rateLimitService: IRateLimitService | null;
sentryProxyEnabled: boolean;
sentryProxyPath: string;
tracing?: TracingOptions;
}
export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions): void {
const {
app,
config,
customMiddleware,
logger,
metricsCollector,
rateLimitService,
sentryProxyEnabled,
sentryProxyPath,
tracing,
} = options;
const {app, customMiddleware, logger, metricsCollector, tracing} = options;
applyMiddlewareStack(app, {
requestId: {},
@ -81,7 +62,7 @@ export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions)
skip: ['/_health'],
},
errorHandler: {
includeStack: !sentryProxyEnabled,
includeStack: true,
logger: (error: Error, ctx: Context) => {
if (!isExpectedError(error)) {
captureException(error, {
@ -103,58 +84,4 @@ export function applyAppProxyMiddleware(options: ApplyAppProxyMiddlewareOptions)
},
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);
}

View File

@ -18,9 +18,8 @@
*/
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 {proxySentry} from '@fluxer/app_proxy/src/app_proxy/proxy/SentryProxy';
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 {Logger} from '@fluxer/logger/src/Logger';
@ -31,46 +30,15 @@ interface RegisterAppProxyRoutesOptions {
assetsPath: string;
cspDirectives?: CSPOptions;
logger: Logger;
sentryProxy: AppProxySentryProxyConfig | null;
sentryProxyPath: string;
sentryProxyRouteEnabled: boolean;
staticCDNEndpoint: string | undefined;
staticDir?: string;
}
export function registerAppProxyRoutes(options: RegisterAppProxyRoutesOptions): void {
const {
app,
assetsPath,
cspDirectives,
logger,
sentryProxy,
sentryProxyPath,
sentryProxyRouteEnabled,
staticCDNEndpoint,
staticDir,
} = options;
const {app, assetsPath, cspDirectives, logger, staticCDNEndpoint, staticDir} = options;
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) {
app.get(`${assetsPath}/*`, (c) =>
proxyAssets(c, {

View File

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

View File

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

View File

@ -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}`;
}

View File

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

View File

@ -18,7 +18,7 @@
*/
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 = {
FRAME: [
@ -71,8 +71,6 @@ export const CSP_HOSTS = {
'https://*.fluxer.workers.dev',
'https://fluxerusercontent.com',
'https://fluxerstatic.com',
'https://sentry.web.fluxer.app',
'https://sentry.web.canary.fluxer.app',
'https://fluxer.media',
'http://127.0.0.1:21863',
'http://127.0.0.1:21864',
@ -95,33 +93,26 @@ export interface CSPOptions {
reportUri?: string;
}
export interface SentryProxyConfig {
sentryProxy: SentryDSN | null;
sentryProxyPath: string;
sentryReportHost: string;
export interface SentryCSPConfig {
sentryDsn: string;
}
export function generateNonce(): string {
return randomBytes(16).toString('hex');
}
export function buildSentryReportURI(config: SentryProxyConfig): string {
const sentry = config.sentryProxy;
export function buildSentryReportURI(config: SentryCSPConfig): string {
const sentry = parseSentryDSN(config.sentryDsn);
if (!sentry) {
return '';
}
const pathPrefix = config.sentryProxyPath.replace(/\/+$/, '');
let uri = `${pathPrefix}/api/${sentry.projectId}/security/?sentry_version=7`;
let uri = `${sentry.targetUrl}${sentry.pathPrefix}/api/${sentry.projectId}/security/?sentry_version=7`;
if (sentry.publicKey) {
uri += `&sentry_key=${sentry.publicKey}`;
}
if (config.sentryReportHost) {
return config.sentryReportHost + uri;
}
return uri;
}
@ -160,8 +151,13 @@ export function buildCSP(nonce: string, options?: CSPOptions): string {
return directives.join('; ');
}
export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
export function buildFluxerCSPOptions(config: SentryCSPConfig): CSPOptions {
const reportURI = buildSentryReportURI(config);
const sentry = parseSentryDSN(config.sentryDsn);
const connectSrc: Array<string> = [...CSP_HOSTS.CONNECT];
if (sentry) {
connectSrc.push(sentry.targetUrl);
}
return {
scriptSrc: [...CSP_HOSTS.SCRIPT],
@ -169,7 +165,7 @@ export function buildFluxerCSPOptions(config: SentryProxyConfig): CSPOptions {
imgSrc: [...CSP_HOSTS.IMAGE],
mediaSrc: [...CSP_HOSTS.MEDIA],
fontSrc: [...CSP_HOSTS.FONT],
connectSrc: [...CSP_HOSTS.CONNECT],
connectSrc: Array.from(new Set(connectSrc)),
frameSrc: [...CSP_HOSTS.FRAME],
workerSrc: [...CSP_HOSTS.WORKER],
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));
}

View File

@ -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": {
"type": "object",
"description": "Configuration for the App Proxy service (frontend server).",
"required": [
"sentry_report_host",
"sentry_dsn"
],
"properties": {
"port": {
"type": "number",
@ -1650,32 +1601,10 @@
"description": "URL endpoint for serving static assets via CDN.",
"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": {
"type": "string",
"description": "Filesystem directory containing static 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",
"default": "",
"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."
}
}
}

View File

@ -54,7 +54,7 @@ function makeMinimalConfig(overrides: Record<string, unknown> = {}): Record<stri
secret_key_base: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
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: {
enabled: false,
port: 8774,

View File

@ -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": {
"type": "object",
"description": "Configuration for the App Proxy service (frontend server).",
"additionalProperties": false,
"required": ["sentry_report_host", "sentry_dsn"],
"properties": {
"port": {
"type": "number",
@ -61,32 +14,10 @@
"description": "URL endpoint for serving static assets via CDN.",
"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": {
"type": "string",
"description": "Filesystem directory containing static 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": {}
}
}
}

View File

@ -111,26 +111,6 @@
"type": "string",
"default": "",
"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."
}
}
}

View File

@ -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', () => {
const input = '<t:0001618953630>';
const flags = 0;

View File

@ -58,6 +58,15 @@ export function parseTimestamp(text: string): ParserResult | 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;
if (stylePart !== undefined) {
if (stylePart === '') {

View File

@ -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.
- **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
- **Google** YouTube embeds.

View File

@ -45,10 +45,6 @@ export type LimitConfigResponse = z.infer<typeof LimitConfigResponse>;
export const AppPublicConfigResponse = z.object({
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>;

View File

@ -91,7 +91,7 @@ if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
read -r CADDY_APP_DOMAIN SENTRY_CADDY_DOMAIN <<EOF
read -r CADDY_APP_DOMAIN <<EOF
$(python3 - <<'PY' "${CONFIG_PATH}"
import sys, json
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()
parsed_app = urlparse(app_url)
app_host = parsed_app.netloc or parsed_app.path
sentry_host_raw = (cfg.get('services', {}).get('app_proxy', {}).get('sentry_report_host') or '').strip()
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}")
print(app_host)
PY
)
EOF
@ -136,17 +131,6 @@ if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
else
API_TARGET="fluxer-api_app"
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 chown -R "${USER}:${USER}" "/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.Referrer-Policy: "strict-origin-when-cross-origin"
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.Pragma: "no-cache"
caddy.header.Expires: "0"
@ -206,22 +189,6 @@ services:
networks: [fluxer-shared]
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:
fluxer-shared:
external: true