fix: various fixes to sentry-reported errors and more

This commit is contained in:
Hampus Kraft
2026-02-18 15:38:51 +00:00
parent 302c0d2a0c
commit 0517a966a3
357 changed files with 25420 additions and 16281 deletions

View File

@@ -105,25 +105,10 @@ function normalizeMarketingSecurityConfig(rawConfig: MarketingConfig): Marketing
allowLocalhost: !isProduction,
allowPrivateIpLiterals: !isProduction,
});
const apiRpcHost = validateOutboundEndpointUrl(resolveApiRpcHost(rawConfig.apiRpcHost), {
name: 'marketing.apiRpcHost',
allowHttp: true,
allowLocalhost: !isProduction,
allowPrivateIpLiterals: !isProduction,
});
return {
...rawConfig,
basePath,
apiEndpoint: normalizeEndpointOrigin(apiEndpoint),
apiRpcHost: normalizeEndpointOrigin(apiRpcHost),
};
}
function resolveApiRpcHost(apiRpcHost: string): string {
const trimmed = apiRpcHost.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.includes('://') ? trimmed : `http://${trimmed}`;
}

View File

@@ -17,64 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {lookupGeoipByIp} from '@fluxer/geoip/src/GeoipLookup';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {readMarketingResponseAsText, sendMarketingRequest} from '@fluxer/marketing/src/MarketingHttpClient';
import {ms} from 'itty-time';
export interface GeoIpSettings {
apiHost: string;
rpcSecret: string;
export interface GeoIpConfig {
geoipDbPath: string;
trustCfConnectingIp: boolean;
}
const defaultCountryCode = 'US';
const DEFAULT_COUNTRY_CODE = 'US';
export async function getCountryCode(req: Request, settings: GeoIpSettings): Promise<string> {
const ip = extractClientIp(req);
if (!ip) return defaultCountryCode;
const url = rpcUrl(settings.apiHost);
if (!url) return defaultCountryCode;
try {
const response = await sendMarketingRequest({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${settings.rpcSecret}`,
},
body: JSON.stringify({type: 'geoip_lookup', ip}),
timeout: ms('5 seconds'),
serviceName: 'marketing_geoip',
});
if (response.status < 200 || response.status >= 300) return defaultCountryCode;
const body = await readMarketingResponseAsText(response.stream);
const code = decodeCountryCode(body);
return code ?? defaultCountryCode;
} catch {
return defaultCountryCode;
export async function getCountryCode(req: Request, config: GeoIpConfig): Promise<string> {
if (!config.geoipDbPath) {
return DEFAULT_COUNTRY_CODE;
}
}
function decodeCountryCode(body: string): string | null {
try {
const parsed = JSON.parse(body) as {data?: {country_code?: string}};
const code = parsed.data?.country_code;
if (!code) return null;
return code.trim().toUpperCase();
} catch {
return null;
const ip = extractClientIp(req, {trustCfConnectingIp: config.trustCfConnectingIp});
if (!ip) {
return DEFAULT_COUNTRY_CODE;
}
}
function rpcUrl(apiHost: string): string {
const host = apiHost.trim();
if (!host) return '';
const base = host.includes('://') ? host : `http://${host}`;
const normalized = base.endsWith('/') ? base.slice(0, -1) : base;
return `${normalized}/_rpc`;
const result = await lookupGeoipByIp(ip, config.geoipDbPath);
return result.countryCode ?? DEFAULT_COUNTRY_CODE;
}

View File

@@ -35,8 +35,8 @@ export interface MarketingConfig {
appEndpoint: string;
staticCdnEndpoint: string;
marketingEndpoint: string;
apiRpcHost: string;
gatewayRpcSecret: string;
geoipDbPath: string;
trustCfConnectingIp: boolean;
releaseChannel: string;
buildTimestamp: string;
rateLimit: RateLimitConfig | null;

View File

@@ -58,8 +58,8 @@ export async function getMarketingRequestInfo(c: Context, config: MarketingConfi
const platform = detectPlatform(userAgent);
const architecture = detectArchitecture(userAgent, platform);
const countryCode = await getCountryCode(c.req.raw, {
apiHost: config.apiRpcHost,
rpcSecret: config.gatewayRpcSecret,
geoipDbPath: config.geoipDbPath,
trustCfConnectingIp: config.trustCfConnectingIp,
});
const referrerDomain = extractReferrerDomain(c.req.header('referer') ?? c.req.header('referrer'));

View File

@@ -20,6 +20,11 @@
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
import type {HttpStatusCode} from '@fluxer/constants/src/HttpConstants';
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {createErrorHandler} from '@fluxer/errors/src/ErrorHandler';
import {FluxerError} from '@fluxer/errors/src/FluxerError';
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';
@@ -29,7 +34,9 @@ import type {MarketingConfig} from '@fluxer/marketing/src/MarketingConfig';
import {cacheHeadersMiddleware} from '@fluxer/marketing/src/middleware/CacheHeadersMiddleware';
import {marketingCsrfMiddleware} from '@fluxer/marketing/src/middleware/Csrf';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {Hono} from 'hono';
import {captureException} from '@fluxer/sentry/src/Sentry';
import {ErrorPage} from '@fluxer/ui/src/pages/ErrorPage';
import type {Context, Hono, ErrorHandler as HonoErrorHandler} from 'hono';
export interface ApplyMarketingMiddlewareStackOptions {
app: Hono;
@@ -76,19 +83,66 @@ export function applyMarketingMiddlewareStack(options: ApplyMarketingMiddlewareS
}
: undefined,
customMiddleware: [cacheHeadersMiddleware(), marketingCsrfMiddleware],
errorHandler: {
includeStack: options.config.env === 'development',
logger: (err, ctx) => {
options.logger.error(
{
error: err.message,
stack: err.stack,
path: ctx.req.path,
method: ctx.req.method,
},
'Request error',
);
},
skipErrorHandler: true,
});
options.app.onError(createMarketingErrorHandler(options.logger, options.config));
}
const KNOWN_HTTP_STATUS_CODES: Array<HttpStatusCode> = Object.values(HttpStatus);
function createMarketingErrorHandler(logger: LoggerInterface, config: MarketingConfig): HonoErrorHandler {
const homeUrl = config.basePath || '/';
return createErrorHandler({
includeStack: config.env === 'development',
logError: (error, c) => {
if (!(error instanceof FluxerError)) {
captureException(error);
}
logger.error(
{
error: error.message,
stack: error.stack,
path: c.req.path,
method: c.req.method,
},
'Request error',
);
},
customHandler: (error, c) => {
const status = getStatus(error) ?? 500;
return renderMarketingError(c, status, homeUrl);
},
});
}
function getStatus(error: Error): number | null {
const statusValue = Reflect.get(error, 'status');
return typeof statusValue === 'number' ? statusValue : null;
}
function renderMarketingError(c: Context, status: number, homeUrl: string): Response | Promise<Response> {
const statusCode = isHttpStatusCode(status) ? status : HttpStatus.INTERNAL_SERVER_ERROR;
c.status(statusCode);
return c.html(
<ErrorPage
statusCode={statusCode}
title="Something went wrong"
description="An unexpected error occurred. Please try again later."
staticCdnEndpoint={CdnEndpoints.STATIC}
homeUrl={homeUrl}
homeLabel="Go home"
/>,
);
}
function isHttpStatusCode(value: number): value is HttpStatusCode {
for (const statusCode of KNOWN_HTTP_STATUS_CODES) {
if (statusCode === value) {
return true;
}
}
return false;
}

View File

@@ -35,7 +35,7 @@ export function Hero(props: HeroProps): JSX.Element {
const {ctx} = props;
return (
<main class="flex flex-col items-center justify-center px-6 pt-44 pb-16 sm:px-8 md:px-12 md:pt-52 md:pb-20 lg:px-16 lg:pb-24 xl:px-20">
<main class="flex flex-col items-center justify-center px-6 pt-36 pb-16 sm:px-8 md:px-12 md:pt-44 md:pb-20 lg:px-16 lg:pb-24 xl:px-20">
<div class="max-w-4xl space-y-8 text-center">
{ctx.locale === 'ja' ? (
<div class="flex justify-center">
@@ -64,8 +64,6 @@ export function Hero(props: HeroProps): JSX.Element {
</div>
<h1 class="hero">{ctx.i18n.getMessage('general.tagline', ctx.locale)}</h1>
<div class="-mt-4 flex items-center justify-center gap-2 font-medium text-sm text-white/80">
<span>{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}</span>
<span class="text-white/40">·</span>
<span class="inline-flex items-center gap-1.5">
<FlagSvg locale={Locales.SV_SE} ctx={ctx} class="h-3.5 w-3.5 rounded-sm" />
{ctx.i18n.getMessage('general.made_in_sweden', ctx.locale)}

View File

@@ -47,18 +47,21 @@ export function Navigation(props: NavigationProps): JSX.Element {
return (
<nav id="navbar" class="fixed top-0 right-0 left-0 z-40">
<input type="checkbox" id="nav-toggle" class="peer hidden" />
<div class="px-6 py-4 sm:px-8 md:px-12 md:py-5 lg:px-16 xl:px-20">
<div class="px-6 py-4 sm:px-8 md:px-12 md:py-5 lg:px-8 xl:px-16">
<div class="mx-auto max-w-7xl rounded-2xl border border-gray-200/60 bg-white/95 px-3 py-2 shadow-lg backdrop-blur-lg md:px-5 md:py-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-6 xl:gap-8">
<div class="flex items-center gap-4 xl:gap-6">
<a
href={href(ctx, '/')}
class="relative z-10 flex items-center transition-opacity hover:opacity-80"
class="relative z-10 flex shrink-0 items-center transition-opacity hover:opacity-80"
aria-label={ctx.i18n.getMessage('navigation.go_home', ctx.locale)}
>
<FluxerLogoWordmarkIcon class="h-8 text-[#4641D9] md:h-9" />
<span class="absolute right-0 -bottom-1.5 whitespace-nowrap rounded-full border border-white bg-[#4641D9] px-1.5 py-0.5 font-bold text-[8px] text-white leading-none">
{ctx.i18n.getMessage('beta_and_access.public_beta', ctx.locale)}
</span>
</a>
<div class="marketing-nav-links hidden items-center gap-6 lg:flex xl:gap-8">
<div class="marketing-nav-links hidden items-center gap-4 lg:flex xl:gap-6">
<a
href={href(ctx, '/download')}
class="body-lg font-semibold text-gray-900/90 transition-colors hover:text-gray-900"
@@ -97,7 +100,7 @@ export function Navigation(props: NavigationProps): JSX.Element {
</a>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1 xl:gap-2">
<a
href="https://bsky.app/profile/fluxer.app"
class="hidden items-center rounded-lg p-2 text-[#4641D9] transition-colors hover:bg-gray-100 hover:text-[#3d38c7] lg:flex"
@@ -118,7 +121,7 @@ export function Navigation(props: NavigationProps): JSX.Element {
</a>
<a
href="https://blog.fluxer.app/rss/"
class="marketing-nav-rss hidden items-center rounded-lg p-2 text-[#4641D9] transition-colors hover:bg-gray-100 hover:text-[#3d38c7] lg:flex"
class="marketing-nav-rss hidden items-center rounded-lg p-2 text-[#4641D9] transition-colors hover:bg-gray-100 hover:text-[#3d38c7] xl:flex"
target="_blank"
rel="noopener noreferrer"
aria-label={ctx.i18n.getMessage('social_and_feeds.rss.label', ctx.locale)}
@@ -132,7 +135,7 @@ export function Navigation(props: NavigationProps): JSX.Element {
<MarketingButton
href={`${ctx.appEndpoint}/channels/@me`}
size="medium"
class="hidden whitespace-nowrap lg:inline-flex"
class="ml-2 hidden whitespace-nowrap lg:inline-flex lg:px-4 lg:py-2 lg:text-sm xl:px-6 xl:py-3 xl:text-base"
>
{ctx.i18n.getMessage('app.open.open_fluxer', ctx.locale)}
</MarketingButton>

View File

@@ -42,7 +42,7 @@ export const POLICY_METADATA: ReadonlyArray<PolicyMetadata> = [
description:
'How we collect, use, and protect your personal information when you use Fluxer. Your privacy matters to us.',
category: 'Legal',
lastUpdated: '2026-02-13',
lastUpdated: '2026-02-18',
},
{
slug: 'guidelines',

View File

@@ -70,7 +70,7 @@ We may also receive information about you from:
- **Other users:** When other users mention you, add you to Communities, send you messages, share content involving you, or otherwise interact with your account.
- **Service providers:** Limited operational information from our service providers, such as transactional email records from SendGrid, account verification records from Twilio, payment confirmations from Stripe, or security-related alerts from infrastructure providers.
- **Service providers:** Limited operational information from our service providers, such as transactional email records from Sweego, account verification records from Twilio, payment confirmations from Stripe, or security-related alerts from infrastructure providers.
- **Public or third-party sources:** In some cases, we may receive information from publicly available sources or trusted partners for security, anti-fraud, or compliance purposes (for example, checking whether an IP address is associated with known abuse).
@@ -179,7 +179,7 @@ To protect your privacy, we route all traffic to KLIPY through our servers, whet
#### Payment and Communications
- **Stripe** payment processing for subscriptions and other purchases.
- **SendGrid** transactional email services.
- **Sweego** transactional email services.
- **Twilio** SMS-based account verification services.
- **Fastmail** support email infrastructure.
@@ -187,7 +187,7 @@ To protect your privacy, we route all traffic to KLIPY through our servers, whet
- **Porkbun** domain registration services.
Many of these providers (for example, OVHcloud, Backblaze, Cloudflare, Stripe, SendGrid, Twilio, Fastmail, and Porkbun in its role as registrar) act as processors and process personal data only on our behalf, according to our instructions.
Many of these providers (for example, OVHcloud, Backblaze, Cloudflare, Stripe, Sweego, Twilio, Fastmail, and Porkbun in its role as registrar) act as processors and process personal data only on our behalf, according to our instructions.
Other providers, such as Google (for YouTube videos), Cloudflare Turnstile, and hCaptcha, may also process some data as independent controllers when you interact directly with their services (for example, when you play an embedded YouTube video or complete a CAPTCHA). In those cases, your use of those services is also governed by their own terms and privacy policies.

View File

@@ -21,7 +21,7 @@
/** @jsxImportSource hono/jsx */
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
import {renderDonationForm} from '@fluxer/marketing/src/pages/donations/DonationForm';
import {renderDonationForm, renderDonationScript} from '@fluxer/marketing/src/pages/donations/DonationForm';
import {renderDonationManageForm} from '@fluxer/marketing/src/pages/donations/DonationManageForm';
import {renderContentLayout} from '@fluxer/marketing/src/pages/Layout';
import {pageMeta} from '@fluxer/marketing/src/pages/layout/Meta';
@@ -76,6 +76,7 @@ function renderDonateContent(ctx: MarketingContext, donationType: 'individual' |
{renderDonationForm(ctx, 'individual', !isIndividual)}
{renderDonationForm(ctx, 'business', !isBusiness)}
{renderDonateTabScript(ctx)}
{renderDonationScript(ctx)}
</div>
<div id="donation-notes" class="mb-12">

View File

@@ -139,7 +139,7 @@ function renderSupportSection(ctx: MarketingContext): JSX.Element {
title={ctx.i18n.getMessage('donations.mobile_roadmap_sponsorship', ctx.locale)}
description={ctx.i18n.getMessage('donations.why_support', ctx.locale)}
>
<div class="flex flex-col gap-6 sm:flex-row sm:flex-wrap sm:justify-center">
<div class="flex flex-col gap-6 sm:flex-row sm:justify-center">
{renderSupportCard(
href(ctx, '/plutonium'),
<CoinsIcon class="h-8 w-8 text-white" />,
@@ -180,7 +180,7 @@ function renderSupportCard(
return (
<a
href={link}
class="flex items-center gap-4 rounded-2xl border border-white/20 bg-white/10 p-6 backdrop-blur-sm transition hover:bg-white/20 sm:w-[calc(50%-0.75rem)] md:p-8"
class="flex items-center gap-4 rounded-2xl border border-white/20 bg-white/10 p-6 backdrop-blur-sm transition hover:bg-white/20 md:p-8"
target={target}
rel={rel}
>

View File

@@ -71,7 +71,6 @@ export function renderDonationForm(
type: 'individual' | 'business',
isHidden: boolean,
): JSX.Element {
const apiEndpoint = ctx.apiEndpoint;
const i18n = getDonationI18n(ctx);
return (
@@ -203,29 +202,33 @@ export function renderDonationForm(
<p id={`donation-error-${type}`} class="hidden text-center text-red-500 text-sm" />
</div>
{renderDonationScript(type, apiEndpoint, i18n)}
</div>
);
}
function renderDonationScript(type: 'individual' | 'business', apiEndpoint: string, i18n: DonationI18n): JSX.Element {
export function renderDonationScript(ctx: MarketingContext): JSX.Element {
const apiEndpoint = ctx.apiEndpoint;
const i18n = getDonationI18n(ctx);
return (
<script
dangerouslySetInnerHTML={{
__html: `var donationState_${type} = {amount: 25, donationType: 'once', currency: 'usd', customAmount: false};
__html: `var donationState = {
individual: {amount: 25, donationType: 'once', currency: 'usd', customAmount: false},
business: {amount: 25, donationType: 'once', currency: 'usd', customAmount: false}
};
function selectDonationAmount(type, amount) {
donationState_${type}.amount = amount;
donationState_${type}.customAmount = false;
donationState[type].amount = amount;
donationState[type].customAmount = false;
var customInput = document.getElementById('custom-amount-' + type);
customInput.classList.add('hidden');
var buttons = document.querySelectorAll('[id^="amount-btn-${type}-"]');
var buttons = document.querySelectorAll('[id^="amount-btn-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'text-[#4641D9]');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var selected = document.getElementById('amount-btn-${type}-' + amount);
var selected = document.getElementById('amount-btn-' + type + '-' + amount);
if (selected) {
selected.classList.add('border-[#4641D9]', 'text-[#4641D9]');
selected.classList.remove('border-gray-200', 'text-gray-700');
@@ -233,39 +236,39 @@ function selectDonationAmount(type, amount) {
}
function showCustomDonationAmount(type) {
donationState_${type}.customAmount = true;
donationState[type].customAmount = true;
var customInput = document.getElementById('custom-amount-' + type);
customInput.classList.remove('hidden');
var buttons = document.querySelectorAll('[id^="amount-btn-${type}-"]');
var buttons = document.querySelectorAll('[id^="amount-btn-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'text-[#4641D9]');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var customBtn = document.getElementById('amount-btn-${type}-custom');
var customBtn = document.getElementById('amount-btn-' + type + '-custom');
customBtn.classList.add('border-[#4641D9]', 'text-[#4641D9]');
customBtn.classList.remove('border-gray-200', 'text-gray-700');
}
function selectDonationType(type, donationType) {
donationState_${type}.donationType = donationType;
var buttons = document.querySelectorAll('[id^="donation-type-${type}-"]');
donationState[type].donationType = donationType;
var buttons = document.querySelectorAll('[id^="donation-type-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var selected = document.getElementById('donation-type-${type}-' + donationType);
var selected = document.getElementById('donation-type-' + type + '-' + donationType);
selected.classList.add('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
selected.classList.remove('border-gray-200', 'text-gray-700');
}
function selectDonationCurrency(type, currency) {
donationState_${type}.currency = currency;
var buttons = document.querySelectorAll('[id^="currency-${type}-"]');
donationState[type].currency = currency;
var buttons = document.querySelectorAll('[id^="currency-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var selected = document.getElementById('currency-${type}-' + currency);
var selected = document.getElementById('currency-' + type + '-' + currency);
selected.classList.add('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
selected.classList.remove('border-gray-200', 'text-gray-700');
}
@@ -281,9 +284,9 @@ async function submitDonation(type) {
return;
}
var amount = donationState_${type}.customAmount
var amount = donationState[type].customAmount
? parseInt(document.getElementById('custom-amount-' + type).value, 10)
: donationState_${type}.amount;
: donationState[type].amount;
if (!amount || amount < 5 || amount > 1000) {
errorEl.textContent = '${escapeInlineScriptValue(i18n.errorInvalidAmount)}';
@@ -296,14 +299,14 @@ async function submitDonation(type) {
btn.textContent = '${escapeInlineScriptValue(i18n.processing)}';
try {
var interval = donationState_${type}.donationType === 'once' ? null : donationState_${type}.donationType;
var interval = donationState[type].donationType === 'once' ? null : donationState[type].donationType;
var response = await fetch('${apiEndpoint}/donations/checkout', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
email: email,
amount_cents: amount * 100,
currency: donationState_${type}.currency,
currency: donationState[type].currency,
interval: interval
})
});

View File

@@ -107,8 +107,14 @@ async function requestManageLink() {
body: JSON.stringify({email: email})
});
msgEl.textContent = manageI18n.success;
msgEl.className = 'mt-2 text-center text-sm text-green-600';
if (response.ok) {
msgEl.textContent = manageI18n.success;
msgEl.className = 'mt-2 text-center text-sm text-green-600';
} else {
var error = await response.json().catch(function() { return {}; });
msgEl.textContent = error.message || manageI18n.errorGeneric;
msgEl.className = 'mt-2 text-center text-sm text-red-500';
}
} catch (err) {
msgEl.textContent = manageI18n.errorNetwork;
msgEl.className = 'mt-2 text-center text-sm text-red-500';