From 528e4e0d7f848bdb4706c7eddc86e93305493a67 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Thu, 19 Feb 2026 00:29:58 +0000 Subject: [PATCH] fix: various fixes to things + simply app proxy sentry setup --- fluxer_app/rspack.config.mjs | 36 +--- .../channel/PreloadableUserPopout.tsx | 8 +- .../modals/UserProfileMobileSheet.tsx | 14 +- .../components/popouts/UserProfilePopout.tsx | 63 ++++--- .../voice/StreamSpectatorsPopout.tsx | 8 +- .../voice/VoiceParticipantAvatarList.tsx | 8 +- fluxer_app/src/index.tsx | 22 +-- fluxer_app/src/lib/AccountStorage.tsx | 4 - .../renderers/TimestampRenderer.test.tsx | 78 ++++++++ .../markdown/renderers/TimestampRenderer.tsx | 31 ++-- .../lib/markdown/utils/DateFormatter.test.tsx | 53 ++++++ .../src/lib/markdown/utils/DateFormatter.tsx | 11 +- .../utils/TimestampValidation.test.tsx | 42 +++++ .../markdown/utils/TimestampValidation.tsx | 38 ++++ fluxer_app/src/stores/InstanceConfigStore.tsx | 4 - fluxer_app/src/stores/RuntimeConfigStore.tsx | 28 --- fluxer_app/src/test/Setup.tsx | 4 - fluxer_app_proxy/src/Config.tsx | 85 +-------- fluxer_app_proxy/src/index.tsx | 33 +--- fluxer_docs/api-reference/openapi.json | 22 +-- fluxer_docs/resources/instance.mdx | 5 - fluxer_docs/self_hosting/configuration.mdx | 172 ++++++------------ packages/admin/src/pages/DiscoveryPage.tsx | 2 +- packages/api/src/Config.tsx | 4 - packages/api/src/config/APIConfig.tsx | 4 - .../api/src/instance/InstanceController.tsx | 4 - packages/app_proxy/src/App.tsx | 12 -- packages/app_proxy/src/AppProxyTypes.tsx | 26 --- .../src/app_proxy/AppProxyMiddleware.tsx | 79 +------- .../src/app_proxy/AppProxyRoutes.tsx | 36 +--- .../app_proxy/middleware/ProxyRateLimit.tsx | 72 -------- .../app_proxy/middleware/SentryHostProxy.tsx | 48 ----- .../src/app_proxy/proxy/SentryProxy.tsx | 77 -------- .../app_proxy/src/app_proxy/utils/Host.tsx | 51 ------ .../app_proxy/src/app_server/utils/CSP.tsx | 32 ++-- packages/config/src/ConfigSchema.json | 91 --------- .../src/__tests__/ConfigLoader.test.tsx | 2 +- .../src/schema/defs/services/app_proxy.json | 69 ------- .../config/src/schema/defs/telemetry.json | 20 -- .../src/__tests__/TimestampParsers.test.tsx | 31 ++++ .../src/parsers/TimestampParsers.tsx | 9 + .../marketing/src/content/policies/privacy.md | 4 + .../src/domains/instance/InstanceSchemas.tsx | 4 - scripts/ci/workflows/deploy_app.py | 37 +--- 44 files changed, 441 insertions(+), 1042 deletions(-) create mode 100644 fluxer_app/src/lib/markdown/renderers/TimestampRenderer.test.tsx create mode 100644 fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx create mode 100644 fluxer_app/src/lib/markdown/utils/TimestampValidation.test.tsx create mode 100644 fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx delete mode 100644 packages/app_proxy/src/app_proxy/middleware/ProxyRateLimit.tsx delete mode 100644 packages/app_proxy/src/app_proxy/middleware/SentryHostProxy.tsx delete mode 100644 packages/app_proxy/src/app_proxy/proxy/SentryProxy.tsx delete mode 100644 packages/app_proxy/src/app_proxy/utils/Host.tsx diff --git a/fluxer_app/rspack.config.mjs b/fluxer_app/rspack.config.mjs index 710252a3..edb89d94 100644 --- a/fluxer_app/rspack.config.mjs +++ b/fluxer_app/rspack.config.mjs @@ -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( diff --git a/fluxer_app/src/components/channel/PreloadableUserPopout.tsx b/fluxer_app/src/components/channel/PreloadableUserPopout.tsx index 8319e238..5b282699 100644 --- a/fluxer_app/src/components/channel/PreloadableUserPopout.tsx +++ b/fluxer_app/src/components/channel/PreloadableUserPopout.tsx @@ -189,7 +189,13 @@ export const PreloadableUserPopout = React.forwardRef< ( - + )} position={position} tooltip={tooltip} diff --git a/fluxer_app/src/components/modals/UserProfileMobileSheet.tsx b/fluxer_app/src/components/modals/UserProfileMobileSheet.tsx index 8a279455..3756ff46 100644 --- a/fluxer_app/src/components/modals/UserProfileMobileSheet.tsx +++ b/fluxer_app/src/components/modals/UserProfileMobileSheet.tsx @@ -129,6 +129,10 @@ export const UserProfileMobileSheet: React.FC = observer(function UserProfileMob ); const [profile, setProfile] = 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(() => { 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 ( ); diff --git a/fluxer_app/src/components/popouts/UserProfilePopout.tsx b/fluxer_app/src/components/popouts/UserProfilePopout.tsx index a286a1ce..2ff1adf0 100644 --- a/fluxer_app/src/components/popouts/UserProfilePopout.tsx +++ b/fluxer_app/src/components/popouts/UserProfilePopout.tsx @@ -112,32 +112,53 @@ export const UserProfilePopout: React.FC = observer( [isWebhook, user.id, guildId, popoutKey], ); - const fetchProfile = useCallback(async () => { - if (isWebhook) return; - - const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : false; - - if (DeveloperOptionsStore.slowProfileLoad) { - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - + useEffect(() => { + let cancelled = false; + const cachedProfile = UserProfileStore.getProfile(user.id, guildId); + setProfile(cachedProfile ?? createMockProfile(user)); setProfileLoadError(false); - try { - const fetchedProfile = await UserProfileActionCreators.fetch(user.id, isGuildMember ? guildId : undefined); - setProfile(fetchedProfile); - 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); + if (isWebhook) { + return () => { + cancelled = 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]); + + return () => { + cancelled = true; + }; + }, [guildId, isWebhook, user]); useEffect(() => { if (profileLoadError && profile) { diff --git a/fluxer_app/src/components/voice/StreamSpectatorsPopout.tsx b/fluxer_app/src/components/voice/StreamSpectatorsPopout.tsx index 9ab4fd50..f6786c8f 100644 --- a/fluxer_app/src/components/voice/StreamSpectatorsPopout.tsx +++ b/fluxer_app/src/components/voice/StreamSpectatorsPopout.tsx @@ -248,7 +248,13 @@ const SpectatorRow = observer(function SpectatorRow({ return ( ( - + )} position="left-start" onOpen={onPopoutOpen} diff --git a/fluxer_app/src/components/voice/VoiceParticipantAvatarList.tsx b/fluxer_app/src/components/voice/VoiceParticipantAvatarList.tsx index c838ee1f..0505be20 100644 --- a/fluxer_app/src/components/voice/VoiceParticipantAvatarList.tsx +++ b/fluxer_app/src/components/voice/VoiceParticipantAvatarList.tsx @@ -157,7 +157,13 @@ function VoiceParticipantPopoutRow({entry, guildId, channelId}: VoiceParticipant return ( ( - + )} position="left-start" > diff --git a/fluxer_app/src/index.tsx b/fluxer_app/src/index.tsx index 09eeedde..f863838b 100644 --- a/fluxer_app/src/index.tsx +++ b/fluxer_app/src/index.tsx @@ -70,8 +70,6 @@ const logger = new Logger('index'); preloadClientInfo(); -const normalizePathSegment = (value: string): string => value.replace(/^\/+|\/+$/g, ''); - async function resumePendingDesktopHandoffLogin(): Promise { const electronApi = getElectronAPI(); if (!electronApi || typeof electronApi.consumeDesktopHandoffCode !== 'function') { @@ -103,7 +101,7 @@ async function resumePendingDesktopHandoffLogin(): Promise { } 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 { await initI18n(); diff --git a/fluxer_app/src/lib/AccountStorage.tsx b/fluxer_app/src/lib/AccountStorage.tsx index 18adb781..e8e30be5 100644 --- a/fluxer_app/src/lib/AccountStorage.tsx +++ b/fluxer_app/src/lib/AccountStorage.tsx @@ -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)) diff --git a/fluxer_app/src/lib/markdown/renderers/TimestampRenderer.test.tsx b/fluxer_app/src/lib/markdown/renderers/TimestampRenderer.test.tsx new file mode 100644 index 00000000..50335c6e --- /dev/null +++ b/fluxer_app/src/lib/markdown/renderers/TimestampRenderer.test.tsx @@ -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 . + */ + +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 { + 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(' { + const props = createRendererProps({ + type: NodeType.Timestamp, + timestamp: 1618953630, + style: TimestampStyle.ShortDateTime, + }); + + const markup = renderToStaticMarkup(React.createElement(TimestampRenderer, props)); + + expect(markup).toContain(' 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 = (
diff --git a/fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx b/fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx new file mode 100644 index 00000000..f8d55513 --- /dev/null +++ b/fluxer_app/src/lib/markdown/utils/DateFormatter.test.tsx @@ -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 . + */ + +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); + }); +}); diff --git a/fluxer_app/src/lib/markdown/utils/DateFormatter.tsx b/fluxer_app/src/lib/markdown/utils/DateFormatter.tsx index a8185039..78ff6c19 100644 --- a/fluxer_app/src/lib/markdown/utils/DateFormatter.tsx +++ b/fluxer_app/src/lib/markdown/utils/DateFormatter.tsx @@ -17,6 +17,7 @@ * along with Fluxer. If not, see . */ +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); diff --git a/fluxer_app/src/lib/markdown/utils/TimestampValidation.test.tsx b/fluxer_app/src/lib/markdown/utils/TimestampValidation.test.tsx new file mode 100644 index 00000000..7fc53640 --- /dev/null +++ b/fluxer_app/src/lib/markdown/utils/TimestampValidation.test.tsx @@ -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 . + */ + +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(); + }); +}); diff --git a/fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx b/fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx new file mode 100644 index 00000000..2c6a324b --- /dev/null +++ b/fluxer_app/src/lib/markdown/utils/TimestampValidation.tsx @@ -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 . + */ + +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; +} diff --git a/fluxer_app/src/stores/InstanceConfigStore.tsx b/fluxer_app/src/stores/InstanceConfigStore.tsx index 2e91cba3..092cb68c 100644 --- a/fluxer_app/src/stores/InstanceConfigStore.tsx +++ b/fluxer_app/src/stores/InstanceConfigStore.tsx @@ -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, diff --git a/fluxer_app/src/stores/RuntimeConfigStore.tsx b/fluxer_app/src/stores/RuntimeConfigStore.tsx index 05a1f559..2bff3f81 100644 --- a/fluxer_app/src/stores/RuntimeConfigStore.tsx +++ b/fluxer_app/src/stores/RuntimeConfigStore.tsx @@ -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; } }); } diff --git a/fluxer_app/src/test/Setup.tsx b/fluxer_app/src/test/Setup.tsx index 6a8a840f..fc2e9391 100644 --- a/fluxer_app/src/test/Setup.tsx +++ b/fluxer_app/src/test/Setup.tsx @@ -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: '', }, }; diff --git a/fluxer_app_proxy/src/Config.tsx b/fluxer_app_proxy/src/Config.tsx index 679cd45c..71cb8deb 100644 --- a/fluxer_app_proxy/src/Config.tsx +++ b/fluxer_app_proxy/src/Config.tsx @@ -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; diff --git a/fluxer_app_proxy/src/index.tsx b/fluxer_app_proxy/src/index.tsx index e2d00f20..1f1933c1 100644 --- a/fluxer_app_proxy/src/index.tsx +++ b/fluxer_app_proxy/src/index.tsx @@ -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 { - 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, diff --git a/fluxer_docs/api-reference/openapi.json b/fluxer_docs/api-reference/openapi.json index 0b0dcbc2..ae665acc 100644 --- a/fluxer_docs/api-reference/openapi.json +++ b/fluxer_docs/api-reference/openapi.json @@ -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" }, diff --git a/fluxer_docs/resources/instance.mdx b/fluxer_docs/resources/instance.mdx index 5d4b5800..3153c6fa 100644 --- a/fluxer_docs/resources/instance.mdx +++ b/fluxer_docs/resources/instance.mdx @@ -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 | @@ -154,4 +150,3 @@ Push notification configuration | Field | Type | Description | |-------|------|-------------| | public_vapid_key | ?string | VAPID public key for web push notifications | - diff --git a/fluxer_docs/self_hosting/configuration.mdx b/fluxer_docs/self_hosting/configuration.mdx index 6b75e564..c8e7fb52 100644 --- a/fluxer_docs/self_hosting/configuration.mdx +++ b/fluxer_docs/self_hosting/configuration.mdx @@ -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<`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: `{}` | -| 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: `""` | ```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": "" } ``` @@ -383,6 +375,30 @@ Development environment flags. --- +## discovery + + + +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` | + + +```json +{ + "enabled": true, + "min_member_count": 1 +} +``` + + +--- + ## domain @@ -481,30 +497,6 @@ Federation configuration for connecting with other Fluxer instances. --- -## gateway_connection - - - -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. | - - -```json -{ - "rpc_secret": "your_rpc_secret", - "rpc_endpoint": "http://127.0.0.1:8088" -} -``` - - ---- - ## geoip @@ -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. ``` +### 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` | + + +```json +{ + "auth_token": "", + "core_url": "nats://127.0.0.1:4222", + "jetstream_url": "nats://127.0.0.1:4223" +} +``` + + ### 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` | ```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 } ``` @@ -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: `""` | ```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": "" } ``` -### 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. | - - -```json -{ - "url": "your_url", - "timeout_ms": 5000 -} -``` - - -### 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` | - - -```json -{ - "limit": 100, - "window_ms": 1000 -} -``` - - ### 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` | ```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" } ``` diff --git a/packages/admin/src/pages/DiscoveryPage.tsx b/packages/admin/src/pages/DiscoveryPage.tsx index 8928aefa..47ecba30 100644 --- a/packages/admin/src/pages/DiscoveryPage.tsx +++ b/packages/admin/src/pages/DiscoveryPage.tsx @@ -184,7 +184,7 @@ export const DiscoveryPage: FC = ({ {app.guild_id} diff --git a/packages/api/src/Config.tsx b/packages/api/src/Config.tsx index 0f7cbd2d..549f1cb8 100644 --- a/packages/api/src/Config.tsx +++ b/packages/api/src/Config.tsx @@ -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: { diff --git a/packages/api/src/config/APIConfig.tsx b/packages/api/src/config/APIConfig.tsx index 45231193..c859fd57 100644 --- a/packages/api/src/config/APIConfig.tsx +++ b/packages/api/src/config/APIConfig.tsx @@ -96,10 +96,6 @@ export interface APIConfig { appPublic: { sentryDsn: string; - sentryProxyPath: string; - sentryReportHost: string; - sentryProjectId: string; - sentryPublicKey: string; }; email: { diff --git a/packages/api/src/instance/InstanceController.tsx b/packages/api/src/instance/InstanceController.tsx index 37e2a5f3..de0e8fd8 100644 --- a/packages/api/src/instance/InstanceController.tsx +++ b/packages/api/src/instance/InstanceController.tsx @@ -95,10 +95,6 @@ export function InstanceController(app: Hono) { }, 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, }, }; diff --git a/packages/app_proxy/src/App.tsx b/packages/app_proxy/src/App.tsx index 1563c5ee..90291173 100644 --- a/packages/app_proxy/src/App.tsx +++ b/packages/app_proxy/src/App.tsx @@ -30,15 +30,10 @@ export interface AppProxyResult { export async function createAppProxyApp(options: CreateAppProxyAppOptions): Promise { 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, }); diff --git a/packages/app_proxy/src/AppProxyTypes.tsx b/packages/app_proxy/src/AppProxyTypes.tsx index 6879367f..cf5c60cb 100644 --- a/packages/app_proxy/src/AppProxyTypes.tsx +++ b/packages/app_proxy/src/AppProxyTypes.tsx @@ -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; export interface CreateAppProxyAppOptions { config: AppProxyConfig; logger: Logger; - rateLimitService?: IRateLimitService | null; metricsCollector?: MetricsCollector; tracing?: TracingOptions; customMiddleware?: Array; - sentryProxyPath?: string; assetsPath?: string; staticCDNEndpoint?: string; staticDir?: string; cspDirectives?: CSPOptions; - sentryProxyEnabled?: boolean; - sentryProxyRouteEnabled?: boolean; } diff --git a/packages/app_proxy/src/app_proxy/AppProxyMiddleware.tsx b/packages/app_proxy/src/app_proxy/AppProxyMiddleware.tsx index 773a6144..cc4ae99c 100644 --- a/packages/app_proxy/src/app_proxy/AppProxyMiddleware.tsx +++ b/packages/app_proxy/src/app_proxy/AppProxyMiddleware.tsx @@ -17,44 +17,25 @@ * along with Fluxer. If not, see . */ -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; - config: AppProxyConfig; customMiddleware: Array; 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; - 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); } diff --git a/packages/app_proxy/src/app_proxy/AppProxyRoutes.tsx b/packages/app_proxy/src/app_proxy/AppProxyRoutes.tsx index 71528e6e..8c1ad0c7 100644 --- a/packages/app_proxy/src/app_proxy/AppProxyRoutes.tsx +++ b/packages/app_proxy/src/app_proxy/AppProxyRoutes.tsx @@ -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, { diff --git a/packages/app_proxy/src/app_proxy/middleware/ProxyRateLimit.tsx b/packages/app_proxy/src/app_proxy/middleware/ProxyRateLimit.tsx deleted file mode 100644 index bbe58f79..00000000 --- a/packages/app_proxy/src/app_proxy/middleware/ProxyRateLimit.tsx +++ /dev/null @@ -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 . - */ - -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; -} - -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); - }, - }); -} diff --git a/packages/app_proxy/src/app_proxy/middleware/SentryHostProxy.tsx b/packages/app_proxy/src/app_proxy/middleware/SentryHostProxy.tsx deleted file mode 100644 index 3b3ec1d0..00000000 --- a/packages/app_proxy/src/app_proxy/middleware/SentryHostProxy.tsx +++ /dev/null @@ -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 . - */ - -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; - proxyHandler: (c: Context) => Promise; -} - -export function createSentryHostProxyMiddleware(options: SentryHostProxyMiddlewareOptions) { - const {proxyHandler, rateLimitMiddleware, sentryHost} = options; - - return async function sentryHostProxy(c: Context, next: Next): Promise { - 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; - }; -} diff --git a/packages/app_proxy/src/app_proxy/proxy/SentryProxy.tsx b/packages/app_proxy/src/app_proxy/proxy/SentryProxy.tsx deleted file mode 100644 index 18066e61..00000000 --- a/packages/app_proxy/src/app_proxy/proxy/SentryProxy.tsx +++ /dev/null @@ -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 . - */ - -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 { - 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}`; -} diff --git a/packages/app_proxy/src/app_proxy/utils/Host.tsx b/packages/app_proxy/src/app_proxy/utils/Host.tsx deleted file mode 100644 index 980b83cd..00000000 --- a/packages/app_proxy/src/app_proxy/utils/Host.tsx +++ /dev/null @@ -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 . - */ - -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); - } -} diff --git a/packages/app_proxy/src/app_server/utils/CSP.tsx b/packages/app_proxy/src/app_server/utils/CSP.tsx index 0ffb02ec..b2195b35 100644 --- a/packages/app_proxy/src/app_server/utils/CSP.tsx +++ b/packages/app_proxy/src/app_server/utils/CSP.tsx @@ -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 = [...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)); } diff --git a/packages/config/src/ConfigSchema.json b/packages/config/src/ConfigSchema.json index 58abd31e..a5c25051 100644 --- a/packages/config/src/ConfigSchema.json +++ b/packages/config/src/ConfigSchema.json @@ -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." } } } diff --git a/packages/config/src/__tests__/ConfigLoader.test.tsx b/packages/config/src/__tests__/ConfigLoader.test.tsx index 9b5055f2..a881ca1c 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.tsx +++ b/packages/config/src/__tests__/ConfigLoader.test.tsx @@ -54,7 +54,7 @@ function makeMinimalConfig(overrides: Record = {}): Record { ]); }); + test('timestamp at max js date boundary parses', () => { + const input = ''; + 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 = ''; + 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 = ``; + 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 = ''; const flags = 0; diff --git a/packages/markdown_parser/src/parsers/TimestampParsers.tsx b/packages/markdown_parser/src/parsers/TimestampParsers.tsx index f63d38d0..314068fe 100644 --- a/packages/markdown_parser/src/parsers/TimestampParsers.tsx +++ b/packages/markdown_parser/src/parsers/TimestampParsers.tsx @@ -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 === '') { diff --git a/packages/marketing/src/content/policies/privacy.md b/packages/marketing/src/content/policies/privacy.md index 78df6dbd..bd2efeab 100644 --- a/packages/marketing/src/content/policies/privacy.md +++ b/packages/marketing/src/content/policies/privacy.md @@ -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. diff --git a/packages/schema/src/domains/instance/InstanceSchemas.tsx b/packages/schema/src/domains/instance/InstanceSchemas.tsx index bb3b22a4..2a3e41f9 100644 --- a/packages/schema/src/domains/instance/InstanceSchemas.tsx +++ b/packages/schema/src/domains/instance/InstanceSchemas.tsx @@ -45,10 +45,6 @@ export type LimitConfigResponse = z.infer; 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; diff --git a/scripts/ci/workflows/deploy_app.py b/scripts/ci/workflows/deploy_app.py index 4819a326..4b54b320 100755 --- a/scripts/ci/workflows/deploy_app.py +++ b/scripts/ci/workflows/deploy_app.py @@ -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 <