Compare commits

...
24 changed files with 420 additions and 600 deletions

No files matched your search

@@ -225,3 +225,55 @@ describe('StorageService.listObjects', () => {
);
});
});
function copySourceFor(endpoint: string, forcePathStyle: boolean): Promise<unknown> {
const service = new StorageService({
endpoint,
forcePathStyle,
region: 'us-east-1',
accessKeyId: 'fluxer',
secretAccessKey: 'fluxer-secret',
});
let copySource: unknown;
Object.assign(service, {
client: {
send: async (command: {input: {CopySource?: unknown}}) => {
copySource = command.input.CopySource;
return {};
},
},
});
return service
.copyObject({
sourceBucket: 'fluxer-uploads',
sourceKey: 'a b/c+d.pdf',
destinationBucket: 'fluxer-cdn',
destinationKey: 'attachments/1/2/c.pdf',
})
.then(() => copySource);
}
describe('StorageService.copyObject copy source', () => {
it.each([
['http://seaweedfs:8333', true],
['http://seaweedfs:8333/', true],
['https://s3.example.test', false],
])(
'names only the bucket and key for an endpoint without a path (%s, path style %s)',
async (endpoint, pathStyle) => {
await expect(copySourceFor(endpoint, pathStyle)).resolves.toBe('fluxer-uploads/a%20b/c%2Bd.pdf');
},
);
it('puts the endpoint path in front of the bucket with path-style addressing', async () => {
await expect(copySourceFor('https://s3.example.test/real%20bucket/sub/', true)).resolves.toBe(
'real%20bucket/sub/fluxer-uploads/a%20b/c%2Bd.pdf',
);
});
it('puts the endpoint path between the bucket and the key with virtual-hosted addressing', async () => {
await expect(copySourceFor('https://s3.example.test/real%20bucket/sub', false)).resolves.toBe(
'fluxer-uploads/real%20bucket/sub/a%20b/c%2Bd.pdf',
);
});
});
@@ -115,6 +115,13 @@ const REJECTED_SERVER_SIDE_COPY_ERRORS = new Set([
'MethodNotAllowed',
]);
function encodedEndpointPath(endpoint: string): Array<string> {
return (URL.parse(endpoint)?.pathname ?? '')
.split('/')
.filter((segment) => segment !== '')
.map((segment) => encodeURIComponent(decodeURIComponent(segment)));
}
function isRejectedServerSideCopy(error: unknown): boolean {
return (
error instanceof S3ServiceException &&
@@ -126,6 +133,7 @@ export class StorageService implements IStorageService {
private readonly client: S3Client;
private readonly presignClient: S3Client;
private readonly provider: S3ProviderSettings;
private readonly endpointPath: Array<string>;
constructor(provider?: S3ProviderSettings) {
this.provider = provider ?? {
@@ -136,6 +144,7 @@ export class StorageService implements IStorageService {
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
};
this.endpointPath = encodedEndpointPath(this.provider.endpoint);
this.client = buildPooledS3Client({
endpoint: this.provider.endpoint,
region: this.provider.region,
@@ -492,7 +501,7 @@ export class StorageService implements IStorageService {
new CopyObjectCommand({
Bucket: destinationBucket,
Key: destinationKey,
CopySource: `${encodeURIComponent(sourceBucket)}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`,
CopySource: this.copySource(sourceBucket, sourceKey),
ContentType: newContentType,
MetadataDirective: newContentType ? 'REPLACE' : undefined,
}),
@@ -508,6 +517,14 @@ export class StorageService implements IStorageService {
}
}
private copySource(bucket: string, key: string): string {
const encodedBucket = encodeURIComponent(bucket);
const location = this.provider.forcePathStyle
? [...this.endpointPath, encodedBucket]
: [encodedBucket, ...this.endpointPath];
return [...location, ...key.split('/').map(encodeURIComponent)].join('/');
}
private async copyObjectThroughApi(
{
sourceBucket,
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {StorageService} from '@app/api/infrastructure/StorageService';
import {server} from '@app/api/test/msw/server';
import {HttpResponse, http} from 'msw';
import {beforeEach, describe, expect, it} from 'vitest';
const HOST = 's3.single-bucket.test';
const REAL_BUCKET = 'realbucket';
function errorResponse(code: string, status: number): HttpResponse<string> {
return new HttpResponse(
`<?xml version="1.0" encoding="UTF-8"?><Error><Code>${code}</Code><Message></Message><RequestId>tx0</RequestId></Error>`,
{status, headers: {'Content-Type': 'application/xml'}},
);
}
function splitBucket(path: string): {bucket: string; key: string} {
const [bucket = '', ...key] = path.split('/');
return {bucket, key: key.join('/')};
}
function singleBucketStore() {
const objects = new Map<string, {body: Uint8Array; contentType: string}>();
const resolve = (url: URL): {bucket: string; key: string} => {
const path = decodeURIComponent(url.pathname.slice(1));
if (url.hostname === HOST) {
return splitBucket(path);
}
return {bucket: url.hostname.slice(0, -HOST.length - 1), key: path};
};
server.use(
http.all(/^https:\/\/([a-z0-9-]+\.)?s3\.single-bucket\.test\//u, async ({request}) => {
const target = resolve(new URL(request.url));
if (target.bucket !== REAL_BUCKET) {
return errorResponse('NoSuchBucket', 404);
}
if (request.method === 'GET') {
const object = objects.get(target.key);
if (!object) {
return errorResponse('NoSuchKey', 404);
}
return new HttpResponse(object.body, {
status: 200,
headers: {'Content-Type': object.contentType, 'Content-Length': String(object.body.length)},
});
}
if (request.method !== 'PUT') {
return errorResponse('NotImplemented', 501);
}
const copySource = request.headers.get('x-amz-copy-source');
if (!copySource) {
objects.set(target.key, {
body: new Uint8Array(await request.arrayBuffer()),
contentType: request.headers.get('content-type') ?? 'application/octet-stream',
});
return new HttpResponse(null, {status: 200, headers: {ETag: '"x"'}});
}
const source = splitBucket(decodeURIComponent(copySource.replace(/^\//u, '')));
if (source.bucket !== REAL_BUCKET) {
return errorResponse('NoSuchBucket', 404);
}
const object = objects.get(source.key);
if (!object) {
return errorResponse('NoSuchKey', 404);
}
objects.set(target.key, {...object, contentType: request.headers.get('content-type') ?? object.contentType});
return new HttpResponse(
'<?xml version="1.0" encoding="UTF-8"?><CopyObjectResult><ETag>"x"</ETag></CopyObjectResult>',
{status: 200, headers: {'Content-Type': 'application/xml'}},
);
}),
);
return objects;
}
function storage(endpoint: string, forcePathStyle: boolean): StorageService {
return new StorageService({
endpoint,
forcePathStyle,
region: 'us-east-1',
accessKeyId: 'TEST',
secretAccessKey: 'TEST',
});
}
const PDF = new TextEncoder().encode('%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
describe('StorageService with every bucket inside one real bucket', () => {
let objects: ReturnType<typeof singleBucketStore>;
beforeEach(() => {
objects = singleBucketStore();
});
it('copies a non-media attachment when the endpoint names the bucket with path-style addressing', async () => {
const service = storage(`https://${HOST}/${REAL_BUCKET}`, true);
await service.uploadObject({bucket: 'fluxer-uploads', key: 'upload 1', body: PDF});
await expect(
service.copyObjectWithMetadataStripping({
sourceBucket: 'fluxer-uploads',
sourceKey: 'upload 1',
destinationBucket: 'fluxer-cdn',
destinationKey: 'attachments/1/2/file.pdf',
contentType: 'application/pdf',
}),
).resolves.toBeNull();
expect(objects.get('fluxer-cdn/attachments/1/2/file.pdf')?.contentType).toBe('application/pdf');
await expect(service.readObject('fluxer-cdn', 'attachments/1/2/file.pdf')).resolves.toEqual(PDF);
});
it('copies within a bucket whose virtual host keeps the endpoint path as a key prefix', async () => {
const service = storage(`https://${HOST}/prefix`, false);
await service.uploadObject({bucket: REAL_BUCKET, key: 'upload-2', body: PDF});
await service.copyObject({
sourceBucket: REAL_BUCKET,
sourceKey: 'upload-2',
destinationBucket: REAL_BUCKET,
destinationKey: 'attachments/1/3/file.pdf',
newContentType: 'application/pdf',
});
expect(objects.get('prefix/attachments/1/3/file.pdf')?.contentType).toBe('application/pdf');
await expect(service.readObject(REAL_BUCKET, 'attachments/1/3/file.pdf')).resolves.toEqual(PDF);
});
});
@@ -54,17 +54,6 @@
width: 100%;
}
.rightSplit {
bottom: 0;
inset-inline-end: 0;
opacity: 1;
pointer-events: none;
position: fixed;
transition: opacity 0.4s ease;
width: auto;
z-index: 0;
}
.leftSplit {
display: flex;
flex-direction: column;
@@ -126,10 +115,9 @@
visibility: hidden;
}
.splashImage {
.splashBackdrop {
position: fixed;
right: 0;
bottom: 0;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
@@ -26,7 +26,6 @@ import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scrolle
import {isMobileExperienceEnabled} from '@app/features/ui/utils/MobileExperience';
import {hasUnavailableElectronNativeContext} from '@app/features/ui/utils/NativeUtils';
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
import Window from '@app/features/window/state/Window';
import {flxElementClassName} from '@app/lib/react';
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
@@ -65,7 +64,6 @@ function resolveAuthLayoutContentMode({current, value}: ResolveAuthLayoutContent
}
const AuthLayoutContent = observer(function AuthLayoutContent({children}: {children?: ReactNode}) {
const {width: viewportWidth, height: viewportHeight} = Window.windowSize;
const [splashUrl, setSplashUrl] = useState<string | null>(null);
const [cardVariant, setCardVariant] = useState<AuthCardVariant>('default');
const [contentMode, setContentMode] = useState<AuthLayoutContentMode | null>(null);
@@ -102,14 +100,6 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
useLayoutEffect(() => {
setContentMode((current) => current ?? AuthLayoutContentMode.CARD);
}, []);
const splashScale = useMemo(() => {
if (!splashDimensions) return null;
const {width, height} = splashDimensions;
if (width <= 0 || height <= 0) return null;
const heightScale = viewportHeight / height;
const widthScale = viewportWidth / width;
return Math.max(heightScale, widthScale);
}, [splashDimensions, viewportHeight, viewportWidth]);
const handleSetContentMode = useCallback<Dispatch<SetStateAction<AuthLayoutContentMode>>>((value) => {
setContentMode((current) => resolveAuthLayoutContentMode({current, value}));
}, []);
@@ -236,11 +226,10 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
data-flx="app.auth-layout.auth-layout-content.character-background"
>
<AuthBackground
className={styles.splashBackdrop}
splashUrl={splashUrl}
splashDimensions={splashDimensions}
splashScale={splashScale}
splashAlignment={splashAlignment}
useFullCover={false}
data-flx="app.auth-layout.auth-layout-content.auth-background"
/>
<flx-app-auth-layout-split
@@ -7,6 +7,8 @@
--account-profile-instance-badge-size: 3rem;
--account-profile-state-badge-size: 2.25rem;
--account-profile-name-color: var(--text-on-brand-primary);
--account-profile-domain-height: 0;
--account-profile-domain-gap: 0;
--account-profile-name-gap: 0.625rem;
--account-profile-name-height: 1.5rem;
--account-profile-more-size: 1.25rem;
@@ -41,6 +43,12 @@
min-width: 0;
}
.tileSlotWithDomain {
--account-profile-domain-height: 1.1rem;
--account-profile-domain-gap: 0.3rem;
--account-profile-name-gap: 0.125rem;
}
.tile {
position: relative;
z-index: 1;
@@ -70,7 +78,12 @@
top: 100%;
right: 0;
left: 0;
height: calc(var(--account-profile-name-gap) + var(--account-profile-name-height));
height: calc(
var(--account-profile-domain-gap) +
var(--account-profile-domain-height) +
var(--account-profile-name-gap) +
var(--account-profile-name-height)
);
content: '';
}
@@ -99,7 +112,8 @@
color: var(--text-secondary);
transition:
background-color 40ms ease,
box-shadow 40ms ease;
box-shadow 40ms ease,
transform 120ms ease-out;
}
.avatarFrame::before {
@@ -134,6 +148,14 @@
box-shadow: 0 0 0 0.125rem color-mix(in srgb, var(--background-modifier-hover) 66%, var(--background-secondary));
}
@media (prefers-reduced-motion: no-preference) {
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.tile:not(:disabled):hover
.avatarFrame {
transform: scale(1.03);
}
}
.tileCurrent .avatarFrame::before {
border-color: transparent;
}
@@ -158,6 +180,28 @@
0 0 0 0.375rem color-mix(in srgb, var(--status-online) 20%, transparent);
}
.domainRow {
position: relative;
z-index: 2;
display: flex;
width: calc(100% - 0.5rem);
height: var(--account-profile-domain-height);
font-size: 0.85rem;
align-items: center;
justify-content: center;
color: var(--text-tertiary-muted);
gap: 0.1rem;
margin: var(--account-profile-domain-gap) auto 0;
pointer-events: none;
}
.domainRow > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nameRow {
position: relative;
z-index: 2;
@@ -154,6 +154,27 @@ function renderInstanceBadge({
);
}
interface RenderInstanceDomainArgs {
readonly instanceBadge: AccountInstanceBadgeInfo | null;
readonly showInstance: boolean;
}
function renderInstanceDomain({instanceBadge, showInstance}: RenderInstanceDomainArgs): React.ReactNode {
if (!showInstance || instanceBadge == null) {
return null;
}
return (
<div
className={styles.domainRow}
data-flx="auth.accounts.account-switcher-modal.account-profile-picker.render-instance-domain.domain-row"
>
<span data-flx="auth.accounts.account-switcher-modal.account-profile-picker.render-instance-domain.span">
{instanceBadge.title}
</span>
</div>
);
}
function renderExpiredAccountStatus(isExpired: boolean, statusLabel: string): React.ReactNode {
if (!isExpired) {
return null;
@@ -238,6 +259,7 @@ const AccountProfileTile = observer(function AccountProfileTile({
const secondaryDisabled = disabled || resolveAccountPickerDisabled(secondaryActionDisabled, account, context);
const selectLabel = resolveSelectLabel({i18n, showInstance, accountTag: tagLabel, instanceLabel});
const removeLabel = i18n._(resolveRemoveAccountDescriptor(context.isCurrent), {accountTag: tagLabel});
const instanceDomain = renderInstanceDomain({instanceBadge, showInstance});
const avatarFrameRef = useRef<HTMLSpanElement | null>(null);
const handlePrimaryAction = useCallback(() => {
if (primaryDisabled) {
@@ -316,7 +338,11 @@ const AccountProfileTile = observer(function AccountProfileTile({
};
return (
<div
className={clsx(styles.tileSlot, context.isExpired && styles.tileSlotExpired)}
className={clsx(
styles.tileSlot,
context.isExpired && styles.tileSlotExpired,
instanceDomain != null && styles.tileSlotWithDomain,
)}
role="listitem"
data-flx="auth.accounts.account-switcher-modal.account-profile-picker.account-profile-tile.tile-slot"
>
@@ -357,6 +383,7 @@ const AccountProfileTile = observer(function AccountProfileTile({
</span>
</button>
</FocusRing>
{instanceDomain}
<div
className={styles.nameRow}
data-flx="auth.accounts.account-switcher-modal.account-profile-picker.account-profile-tile.name-row"
@@ -59,10 +59,22 @@
}
.accountsStage {
position: relative;
min-width: 0;
pointer-events: none;
}
.shortcutTitle {
position: absolute;
bottom: 100%;
left: 0;
display: flex;
width: 100%;
justify-content: center;
padding-bottom: 0.625rem;
pointer-events: none;
}
.authStage {
display: flex;
min-width: 0;
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {AccountProfilePicker} from '@app/features/auth/components/accounts/account_switcher_modal/AccountProfilePicker';
import {
type AccountSwitcherContentView,
@@ -7,9 +8,11 @@ import {
} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherModalTypes';
import styles from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherOverlay.module.css';
import {AccountSwitcherViewTransition} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherViewTransition';
import Keybind, {type KeybindCommand} from '@app/features/input/state/InputKeybind';
import type {Account} from '@app/features/platform/state/AuthSession';
import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope';
import {usePrefersReducedMotion} from '@app/features/ui/hooks/usePrefersReducedMotion';
import {KeybindHint} from '@app/features/ui/keybind_hint/KeybindHint';
import {AnimeButton, type AnimeTarget, createAnimeFlxElement} from '@app/features/ui/motion/AnimeElement';
import LayerManager, {LayerType} from '@app/features/ui/state/LayerManager';
import {getZIndexForStack} from '@app/features/ui/state/Modal';
@@ -18,12 +21,15 @@ import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {flxElementClassName} from '@app/lib/react';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useContext, useEffect, useId, useRef} from 'react';
const AccountSwitcherAnimatedRoot = createAnimeFlxElement('flx-auth-account-switcher-overlay');
const AccountSwitcherAnimatedShell = createAnimeFlxElement('flx-auth-account-switcher-overlay-shell');
const SYSTEM_OPEN_ACCOUNT_SWITCHER_ACTION: KeybindCommand = 'system_open_account_switcher';
const CLOSE_ACCOUNT_SWITCHER_DESCRIPTOR = msg({
message: 'Close account switcher',
comment: 'Accessible label for closing the account switcher overlay.',
@@ -48,6 +54,24 @@ interface AccountSwitcherStageProps {
readonly view: AccountSwitcherContentView;
}
const AccountSwitcherShortcutHint = observer(function AccountSwitcherShortcutHint(): React.ReactElement | null {
const {combo} = Keybind.getByAction(SYSTEM_OPEN_ACCOUNT_SWITCHER_ACTION);
if (Accessibility.hideKeyboardHints || (!combo.key && !combo.code)) {
return null;
}
return (
<div
className={styles.shortcutTitle}
data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-shortcut-hint.shortcut-title"
>
<KeybindHint
combo={combo}
data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-shortcut-hint.keybind-hint"
/>
</div>
);
});
function AccountSwitcherStage({
accounts,
authContent,
@@ -73,6 +97,7 @@ function AccountSwitcherStage({
className={flxElementClassName(styles.accountsStage)}
data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-stage.accounts-stage"
>
<AccountSwitcherShortcutHint data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-stage.account-switcher-shortcut-hint" />
{accounts.length === 0 ? (
<flx-auth-account-switcher-overlay-empty
className={flxElementClassName(styles.noAccounts)}
@@ -5,91 +5,49 @@ import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type React from 'react';
const getSplashAlignmentStyles = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
const getSplashObjectPosition = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
switch (alignment) {
case GuildSplashCardAlignment.LEFT:
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
return 'left bottom';
case GuildSplashCardAlignment.RIGHT:
return {transformOrigin: 'bottom right', objectPosition: 'right bottom'};
return 'right bottom';
default:
return {transformOrigin: 'bottom center', objectPosition: 'center bottom'};
return 'center bottom';
}
};
export interface AuthBackgroundProps {
splashUrl: string | null;
splashDimensions?: {width: number; height: number} | null;
splashScale?: number | null;
patternImageUrl?: string | null;
className?: string;
useFullCover?: boolean;
splashAlignment?: ValueOf<typeof GuildSplashCardAlignment>;
}
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
splashUrl,
splashDimensions,
splashScale,
patternImageUrl,
className,
useFullCover = false,
splashAlignment = GuildSplashCardAlignment.CENTER,
}) => {
const shouldShowSplash = splashUrl && splashDimensions && (useFullCover || splashScale);
const {transformOrigin, objectPosition} = getSplashAlignmentStyles(splashAlignment);
if (shouldShowSplash) {
if (useFullCover) {
return (
<div className={className} data-flx="auth.flow.auth-background.div">
<div style={{position: 'absolute', inset: 0}} data-flx="auth.flow.auth-background.div--2">
<img
src={splashUrl}
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition,
}}
data-flx="auth.flow.auth-background.img"
/>
<div className={styles.splashOverlay} data-flx="auth.flow.auth-background.splash-overlay" />
</div>
</div>
);
}
if (splashUrl && splashDimensions) {
return (
<div className={styles.rightSplit} data-flx="auth.flow.auth-background.right-split">
<div
className={styles.splashImage}
<div className={className} data-flx="auth.flow.auth-background.div">
<img
src={splashUrl}
alt=""
style={{
width: splashDimensions.width,
height: splashDimensions.height,
transform: `scale(${splashScale})`,
transformOrigin,
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: getSplashObjectPosition(splashAlignment),
}}
data-flx="auth.flow.auth-background.splash-image"
>
<img
src={splashUrl}
alt=""
width={splashDimensions.width}
height={splashDimensions.height}
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition,
}}
data-flx="auth.flow.auth-background.img--2"
/>
<div className={styles.splashOverlay} data-flx="auth.flow.auth-background.splash-overlay--2" />
</div>
data-flx="auth.flow.auth-background.img"
/>
<div className={styles.splashOverlay} data-flx="auth.flow.auth-background.splash-overlay" />
</div>
);
}
@@ -294,7 +294,7 @@ export const ChannelSourcePreview = observer(function ChannelSourcePreview({
{renderChannelAvatar(channel, resolvedAvatarSize, avatarFallbackLabels)}
<div className={styles.textContainer} data-flx="channel.channel-source-preview.text-container">
<div className={styles.nameRow} data-flx="channel.channel-source-preview.name-row">
{channel.guildId ? ChannelUtils.getIcon(channel, {weight: 'bold'}) : null}
{channel.guildId ? ChannelUtils.getIcon(channel, {className: styles.channelIcon, weight: 'bold'}) : null}
{renderNameAction()}
{mentionCount != null && mentionCount > 0 ? (
<MentionBadge
@@ -125,7 +125,6 @@ export const InvitePagePreviewModal: React.FC<InvitePagePreviewModalProps> = obs
splashDimensions={splashDimensions}
patternImageUrl={patternReady ? foodPatternUrl : null}
splashAlignment={splashAlignment}
useFullCover={true}
data-flx="invite.invite-page-preview-modal.background"
/>
<div className={styles.foreground} data-flx="invite.invite-page-preview-modal.foreground">
@@ -280,7 +280,6 @@
max-height: 100%;
min-width: 0;
min-height: 0;
will-change: transform;
pointer-events: none;
}
@@ -23,7 +23,6 @@ finish-args:
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=org.ayatana.indicator.application
- --talk-name=com.canonical.indicator.application
- --talk-name=org.freedesktop.secrets
- --talk-name=org.gnome.Mutter.IdleMonitor
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
build-options:
@@ -23,7 +23,6 @@ finish-args:
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=org.ayatana.indicator.application
- --talk-name=com.canonical.indicator.application
- --talk-name=org.freedesktop.secrets
- --talk-name=org.gnome.Mutter.IdleMonitor
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
build-options:
@@ -65,7 +65,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url: 'https://media.internal/avatars/1.webp',
@@ -104,7 +103,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url: 'https://media.internal/avatars/1.webp',
@@ -161,7 +159,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url: 'https://media.mixed.example/avatars/1.webp',
@@ -193,44 +190,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
outboundHTTP.cleanup();
});
test('a request that cannot be constructed releases its in-flight slot', async () => {
const {port, server} = await startServer({
'/ok': (_request, response) => sendImage(response, 'image/png', PNG_BYTES),
});
try {
const outboundHTTP = new DesktopOutboundHTTP({
resolveHostAddresses: stubResolver({'lan.example': ['127.0.0.1']}),
});
await outboundHTTP.registerAnchoredOrigins({
anchorOrigin: `http://lan.example:${port}`,
origins: [`http://lan.example:${port}`],
});
const send = (headers) =>
outboundHTTP.request({
body: null,
expectedOrigin: `http://lan.example:${port}`,
headers,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'slot-leak',
signal: null,
timeoutMs: 2000,
url: `http://lan.example:${port}/ok`,
});
for (let attempt = 0; attempt < 80; attempt += 1) {
await assert.rejects(send({'x-bad': 'a\u0001b'}));
}
const message = await send(null);
assert.equal(message.status, 200);
message.message.resume();
outboundHTTP.cleanup();
} finally {
server.close();
}
});
test('a request whose signal already aborted is refused instead of sent', async () => {
const seen = [];
const {port, server} = await startServer({
@@ -257,7 +216,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'aborted',
signal: controller.signal,
timeoutMs: 2000,
url: `http://lan.example:${port}/ok`,
@@ -289,7 +247,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 2000,
url: `http://media.lan.example:${port}/media/ok`,
@@ -331,7 +288,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: {'content-type': 'application/json', 'x-fluxer-sudo-mode-jwt': 'previous'},
method,
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 2000,
url: `http://lan.example:${port}/api/v1/oauth2/applications/1`,
@@ -549,7 +505,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.BOUND,
serviceName: 'test',
signal,
timeoutMs: 2000,
url: `https://home.example:${port}/ping`,
@@ -577,7 +532,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.BOUND,
serviceName: 'test',
signal: null,
timeoutMs: 200,
url: 'http://blackhole.example:9/ping',
@@ -606,7 +560,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.BOUND,
serviceName: 'test',
signal: null,
timeoutMs: 2000,
url: `http://mixed.example:${port}/ping`,
@@ -632,7 +585,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.BOUND,
serviceName: 'test',
signal: null,
timeoutMs: 2000,
url: `http://127.0.0.1:${other.port}/steal`,
@@ -670,7 +622,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.BOUND,
serviceName: 'test',
signal: null,
timeoutMs: 5000,
url: `http://127.0.0.1:${port}/ping`,
@@ -720,7 +671,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: {'content-type': 'text/plain'},
method: 'PUT',
originTrust: DesktopOriginTrust.BOUND,
serviceName: 'test',
signal: null,
timeoutMs: 5000,
url: `http://127.0.0.1:${port}/upload`,
@@ -775,7 +725,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 2000,
url: 'https://unrouted.example/',
@@ -826,7 +775,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 2000,
url: `http://dual.example:${port}/ping`,
@@ -857,7 +805,6 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 3000,
url: `http://multi.example:${port}/ping`,
@@ -1007,7 +954,6 @@ describe('DesktopOutboundHTTP system proxy routes', () => {
headers: {Accept: 'image/png'},
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url: 'https://media.blocked.example/avatar.png',
@@ -1061,7 +1007,6 @@ describe('DesktopOutboundHTTP system proxy routes', () => {
headers: null,
method: 'GET',
originTrust,
serviceName: 'test',
signal: null,
timeoutMs: 500,
url,
+3 -55
View File
@@ -28,8 +28,6 @@ const DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS = 10_000;
const DESKTOP_OUTBOUND_HTTP_MAX_TARGET_URL_BYTES = 16 * 1024;
const DESKTOP_OUTBOUND_HTTP_MAX_REDIRECT_LOCATION_BYTES = 4096;
const DESKTOP_OUTBOUND_HTTP_MAX_ORIGINS = 2048;
const DESKTOP_OUTBOUND_HTTP_MAX_ORIGIN_REQUESTS_IN_FLIGHT = 128;
const DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE = 64;
const DESKTOP_OUTBOUND_HTTP_MAX_PENDING_RESOLUTIONS = 64;
const DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
const DESKTOP_OUTBOUND_HTTP_STALE_SOCKET_ATTEMPTS = 1;
@@ -113,7 +111,6 @@ export interface DesktopOutboundHTTPRequest {
readonly maximumRequestBodyBytes?: number;
readonly method: string;
readonly originTrust: DesktopOriginTrust;
readonly serviceName: string;
readonly signal: AbortSignal | null;
readonly timeoutMs: number;
readonly url: string;
@@ -565,7 +562,7 @@ interface DesktopProxiedSend {
readonly body: Uint8Array | Readable | null;
readonly headers: Readonly<Record<string, string>>;
readonly method: string;
readonly release: () => void;
readonly release?: () => void;
readonly signal: AbortSignal | null;
readonly target: URL;
readonly timeout: () => Error;
@@ -578,20 +575,15 @@ export class DesktopOutboundHTTP {
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS,
});
private readonly httpsAgent: https.Agent;
private readonly originRequestHttpAgent = new HappyEyeballsHttpAgent({
keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
});
private readonly originRequestHttpAgent = new HappyEyeballsHttpAgent({keepAlive: true});
private readonly originRequestHttpsAgent: https.Agent;
private readonly bindings = new Map<string, DesktopOriginAddressBinding>();
private readonly pendingBindings = new Map<string, DesktopPendingOriginBinding>();
private readonly registeredRequirements = new Map<string, DesktopAddressRequirement>();
private readonly originRequestsInFlightByService = new Map<string, number>();
private readonly resolveHostAddresses: DesktopHostAddressResolver;
private readonly resolveProxy: DesktopProxyResolver;
private readonly sendThroughSession: DesktopSessionHTTPSender;
private inFlight = 0;
private originRequestsInFlight = 0;
private activeResolutions = 0;
private acceptingRequests = true;
@@ -608,7 +600,6 @@ export class DesktopOutboundHTTP {
});
this.originRequestHttpsAgent = new HappyEyeballsHttpsAgent({
keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
...trust,
});
}
@@ -783,7 +774,6 @@ export class DesktopOutboundHTTP {
this.originRequestHttpsAgent.destroy();
this.bindings.clear();
this.registeredRequirements.clear();
this.originRequestsInFlightByService.clear();
}
private async routesDirect(url: URL): Promise<boolean> {
@@ -813,15 +803,6 @@ export class DesktopOutboundHTTP {
request: DesktopOutboundHTTPRequest,
target: URL,
): Promise<DesktopOutboundHTTPMessage> {
this.acquireSlot(request.serviceName);
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.releaseSlot(request.serviceName);
};
return this.sendProxied({
body: this.proxiedRequestBody(request),
headers: requestHeaders(request.headers),
@@ -830,7 +811,6 @@ export class DesktopOutboundHTTP {
target,
timeout: () => new DesktopOutboundHTTPRequestTimeoutError(target.toString(), request.timeoutMs),
timeoutMs: request.timeoutMs,
release,
}).catch((error: unknown) => {
if (request.signal?.aborted === true) {
throw new DesktopOutboundHTTPRequestAbortedError(target.toString());
@@ -854,7 +834,7 @@ export class DesktopOutboundHTTP {
timer.unref();
const settle = (): void => {
clearTimeout(timer);
release();
release?.();
};
try {
const response = await this.sendThroughSession({
@@ -1083,42 +1063,11 @@ export class DesktopOutboundHTTP {
}
}
private acquireSlot(serviceName: string): void {
if (this.originRequestsInFlight >= DESKTOP_OUTBOUND_HTTP_MAX_ORIGIN_REQUESTS_IN_FLIGHT) {
throw new DesktopOutboundHTTPCapacityError('in-flight request');
}
const perService = this.originRequestsInFlightByService.get(serviceName) ?? 0;
if (perService >= DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE) {
throw new DesktopOutboundHTTPCapacityError(`in-flight request for ${serviceName}`);
}
this.originRequestsInFlight += 1;
this.originRequestsInFlightByService.set(serviceName, perService + 1);
}
private releaseSlot(serviceName: string): void {
this.originRequestsInFlight -= 1;
const perService = (this.originRequestsInFlightByService.get(serviceName) ?? 1) - 1;
if (perService <= 0) {
this.originRequestsInFlightByService.delete(serviceName);
} else {
this.originRequestsInFlightByService.set(serviceName, perService);
}
}
private issueOriginRequest(
request: DesktopOutboundHTTPRequest,
target: URL,
binding: DesktopOriginAddressBinding,
): Promise<DesktopOutboundHTTPMessage> {
this.acquireSlot(request.serviceName);
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.releaseSlot(request.serviceName);
};
return new Promise<DesktopOutboundHTTPMessage>((resolve, reject) => {
let attemptsRemaining = isReplayableRequest(request) ? DESKTOP_OUTBOUND_HTTP_STALE_SOCKET_ATTEMPTS : 0;
let active: http.ClientRequest | null = null;
@@ -1144,7 +1093,6 @@ export class DesktopOutboundHTTP {
const settle = (): void => {
clearTimeout(timeout);
request.signal?.removeEventListener('abort', onAbort);
release();
};
const destroyActive = (error: Error): void => {
active?.destroy(error);
@@ -16,10 +16,12 @@ function createLogger() {
function createSession() {
const session = {
requestFilter: null,
requestListener: null,
webRequest: {
onBeforeSendHeaders: (listener) => {
session.requestListener = listener;
onBeforeSendHeaders: (...args) => {
session.requestFilter = args.length > 1 ? args[0] : null;
session.requestListener = args.at(-1);
},
},
};
@@ -141,6 +143,12 @@ describe('DesktopRuntimeSecurity', () => {
);
});
test('only intercepts local app requests and the YouTube embed frame', () => {
const {session} = createInstalled();
assert.deepEqual(session.requestFilter, {urls: ['fluxer-app://*/*', 'https://www.youtube.com/embed/*']});
});
test('installs once per session and stops listening after cleanup', () => {
const {security, session} = createInstalled();
const firstRequestListener = session.requestListener;
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {STABLE_APP_URL} from '@electron/common/Constants';
import {DESKTOP_APP_SCHEME, STABLE_APP_URL} from '@electron/common/Constants';
import {isPlainRecord} from '@electron/common/PlainRecord';
const SUB_FRAME_RESOURCE_TYPE = 'subFrame';
@@ -9,6 +9,10 @@ const REFERER_REQUEST_HEADER = 'Referer';
const REFERER_HEADER_NAME_LOWERCASE = 'referer';
const YOUTUBE_EMBED_FRAME_ORIGIN = 'https://www.youtube.com';
const YOUTUBE_EMBED_FRAME_PATH_PREFIX = '/embed/';
const DESKTOP_RUNTIME_SECURITY_REQUEST_URL_PATTERNS = [
`${DESKTOP_APP_SCHEME}://*/*`,
`${YOUTUBE_EMBED_FRAME_ORIGIN}${YOUTUBE_EMBED_FRAME_PATH_PREFIX}*`,
];
interface DesktopRuntimeSecurityLogger {
error: (...args: Array<unknown>) => void;
@@ -62,9 +66,12 @@ export class DesktopRuntimeSecurity {
}
this.installedSessions.add(session);
try {
session.webRequest.onBeforeSendHeaders((details: unknown, callback: unknown) => {
this.settleRequestHeaders(details, callback);
});
session.webRequest.onBeforeSendHeaders(
{urls: DESKTOP_RUNTIME_SECURITY_REQUEST_URL_PATTERNS},
(details: unknown, callback: unknown) => {
this.settleRequestHeaders(details, callback);
},
);
} catch (installationError) {
this.uninstall(session);
throw installationError;
@@ -41,7 +41,6 @@ async function fetchThrough(certificates, port) {
headers: null,
method: 'GET',
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: 'test',
signal: null,
timeoutMs: 3000,
url: `https://ca.example:${port}/`,
+10 -169
View File
@@ -20,8 +20,6 @@ const LOCAL_APP_PROXY_REQUEST_BODY_MAX_BYTES = 100 * 1024 * 1024;
export const LOCAL_APP_PROXY_ACCEPT_ENCODING = 'gzip, br';
const LOCAL_APP_PROXY_RESOURCE_RESPONSE_HEADERS_DEADLINE_MS = 30_000;
const LOCAL_APP_PROXY_MAX_CONCURRENT_REQUESTS = 32;
const LOCAL_APP_PROXY_MAX_QUEUED_REQUESTS = 4096;
const UPLOAD_PROGRESS_MIN_BYTES = 64 * 1024;
const UPLOAD_PROGRESS_MIN_INTERVAL_MS = 100;
const MINIMUM_RESPONSE_STATUS = 200;
@@ -42,101 +40,6 @@ class LocalAppProxyResponseHeadersDeadlineError extends Error {
}
}
class LocalAppProxyQueueOverflowError extends Error {
public constructor() {
super('Local app proxy has too many queued requests');
this.name = 'LocalAppProxyQueueOverflowError';
}
}
class LocalAppProxyConcurrencyPermit {
private releasePermit: (() => void) | null;
public constructor(releasePermit: () => void) {
this.releasePermit = releasePermit;
}
public release(): void {
const releasePermit = this.releasePermit;
if (releasePermit == null) {
return;
}
this.releasePermit = null;
releasePermit();
}
}
class LocalAppProxyResponseBodyLease {
public readonly reader: ReadableStreamDefaultReader<Uint8Array>;
private readonly permit: LocalAppProxyConcurrencyPermit;
private settled = false;
public constructor(body: ReadableStream<Uint8Array>, permit: LocalAppProxyConcurrencyPermit) {
this.reader = body.getReader();
this.permit = permit;
}
public release(): void {
if (this.settled) {
return;
}
this.settled = true;
try {
this.reader.releaseLock();
} finally {
this.permit.release();
}
}
}
class LocalAppProxyConcurrencyGate {
private active = 0;
private readonly waiting: Array<{readonly admit: () => void}> = [];
private readonly newestFirst: boolean;
public constructor(newestFirst: boolean) {
this.newestFirst = newestFirst;
}
public acquire(signal: AbortSignal): Promise<LocalAppProxyConcurrencyPermit> {
signal.throwIfAborted();
if (this.active < LOCAL_APP_PROXY_MAX_CONCURRENT_REQUESTS) {
this.active += 1;
return Promise.resolve(new LocalAppProxyConcurrencyPermit(() => this.release()));
}
if (this.waiting.length >= LOCAL_APP_PROXY_MAX_QUEUED_REQUESTS) {
throw new LocalAppProxyQueueOverflowError();
}
return new Promise<LocalAppProxyConcurrencyPermit>((resolve, reject) => {
let waiter: (typeof this.waiting)[number];
const onAbort = () => {
const index = this.waiting.indexOf(waiter);
if (index === -1) {
return;
}
this.waiting.splice(index, 1);
reject(signal.reason);
};
const admit = () => {
signal.removeEventListener('abort', onAbort);
resolve(new LocalAppProxyConcurrencyPermit(() => this.release()));
};
waiter = {admit};
this.waiting.push(waiter);
signal.addEventListener('abort', onAbort, {once: true});
});
}
private release(): void {
const next = this.newestFirst ? this.waiting.pop() : this.waiting.shift();
if (next != null) {
next.admit();
return;
}
this.active -= 1;
}
}
interface DesktopLocalAppProxyClientDependencies {
readonly outboundHTTP: DesktopOutboundHTTP;
readonly traffic: LocalAppProxyTraffic;
@@ -226,35 +129,20 @@ function emitLocalAppUploadProgress(progress: DesktopLocalAppUploadProgress): vo
export class DesktopLocalAppProxyClient {
private readonly outboundHTTP: DesktopOutboundHTTP;
private readonly traffic: LocalAppProxyTraffic;
private readonly concurrency: LocalAppProxyConcurrencyGate;
private readonly responseHeadersDeadlineMs: number | null;
public constructor(dependencies: DesktopLocalAppProxyClientDependencies) {
this.outboundHTTP = dependencies.outboundHTTP;
this.traffic = dependencies.traffic;
const remoteResource = dependencies.traffic === LocalAppProxyTraffic.REMOTE_RESOURCE;
this.concurrency = new LocalAppProxyConcurrencyGate(remoteResource);
this.responseHeadersDeadlineMs = remoteResource ? LOCAL_APP_PROXY_RESOURCE_RESPONSE_HEADERS_DEADLINE_MS : null;
this.responseHeadersDeadlineMs =
dependencies.traffic === LocalAppProxyTraffic.REMOTE_RESOURCE
? LOCAL_APP_PROXY_RESOURCE_RESPONSE_HEADERS_DEADLINE_MS
: null;
}
public async fetch(request: LocalAppProxyFetchRequest): Promise<Response> {
const permit = await this.concurrency.acquire(request.signal);
try {
return await this.runFetchWithinHeadersDeadline(request, permit);
} catch (error) {
permit.release();
throw error;
}
}
private async runFetchWithinHeadersDeadline(
request: LocalAppProxyFetchRequest,
permit: LocalAppProxyConcurrencyPermit,
): Promise<Response> {
const deadlineMs = this.responseHeadersDeadlineMs;
if (deadlineMs == null || requestHasBody(request.method)) {
return await this.runFetch(request, permit);
return await this.runFetch(request);
}
const deadline = new AbortController();
const timer = setTimeout(
@@ -263,16 +151,13 @@ export class DesktopLocalAppProxyClient {
);
timer.unref();
try {
return await this.runFetch({...request, signal: AbortSignal.any([request.signal, deadline.signal])}, permit);
return await this.runFetch({...request, signal: AbortSignal.any([request.signal, deadline.signal])});
} finally {
clearTimeout(timer);
}
}
private async runFetch(
request: LocalAppProxyFetchRequest,
permit: LocalAppProxyConcurrencyPermit,
): Promise<Response> {
private async runFetch(request: LocalAppProxyFetchRequest): Promise<Response> {
const target = new URL(request.targetURL);
const body = requestHasBody(request.method) ? request.body : null;
const outboundRequest: DesktopOutboundHTTPRequest = {
@@ -282,12 +167,11 @@ export class DesktopLocalAppProxyClient {
maximumRequestBodyBytes: LOCAL_APP_PROXY_REQUEST_BODY_MAX_BYTES,
method: request.method,
originTrust: DesktopOriginTrust.REGISTERED,
serviceName: this.traffic,
signal: request.signal,
timeoutMs: LOCAL_APP_PROXY_DEADLINE_MS,
url: target.href,
};
return buildProxyResponse(await this.outboundHTTP.request(outboundRequest), request.method, permit);
return buildProxyResponse(await this.outboundHTTP.request(outboundRequest), request.method);
}
public async settleFailure({
@@ -368,11 +252,7 @@ function instrumentedRequestBody(
);
}
function buildProxyResponse(
message: DesktopOutboundHTTPMessage,
method: string,
permit: LocalAppProxyConcurrencyPermit,
): Response {
function buildProxyResponse(message: DesktopOutboundHTTPMessage, method: string): Response {
try {
const status = message.status;
if (status < MINIMUM_RESPONSE_STATUS || status > MAXIMUM_RESPONSE_STATUS) {
@@ -381,59 +261,20 @@ function buildProxyResponse(
const headers = responseHeaders(message.headers);
if (method === LOCAL_APP_PROXY_HEAD_METHOD || NULL_BODY_STATUSES.has(status)) {
message.message.resume();
permit.release();
return new Response(null, {status, statusText: message.statusText, headers});
}
const decoded = decodedMessageStream(message.message, contentEncoding(message.headers));
const body = holdPermitForResponseBody(Readable.toWeb(decoded) as ReadableStream<Uint8Array>, permit);
return new Response(body, {
return new Response(Readable.toWeb(decoded) as ReadableStream<Uint8Array>, {
status,
statusText: message.statusText,
headers,
});
} catch (error) {
message.message.destroy();
permit.release();
throw error;
}
}
function holdPermitForResponseBody(
body: ReadableStream<Uint8Array>,
permit: LocalAppProxyConcurrencyPermit,
): ReadableStream<Uint8Array> {
const lease = new LocalAppProxyResponseBodyLease(body, permit);
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const chunk = await lease.reader.read();
if (chunk.done) {
try {
controller.close();
} finally {
lease.release();
}
return;
}
controller.enqueue(chunk.value);
} catch (error) {
try {
controller.error(error);
} finally {
lease.release();
}
}
},
async cancel(reason) {
try {
await lease.reader.cancel(reason);
} finally {
lease.release();
}
},
});
}
function responseHeaders(incoming: http.IncomingHttpHeaders): Headers {
const headers = new Headers();
for (const [name, value] of Object.entries(incoming)) {
@@ -2,7 +2,7 @@
import assert from 'node:assert/strict';
import {Buffer} from 'node:buffer';
import {Readable} from 'node:stream';
import {PassThrough, Readable} from 'node:stream';
import {describe, mock, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
@@ -10,17 +10,9 @@ installElectronStub();
const {DesktopLocalAppProxyClient, LocalAppProxyTraffic} = await import('./LocalAppProxyClient.ts');
const PROXY_MAX_CONCURRENT_REQUESTS = 32;
const PROXY_MAX_QUEUED_REQUESTS = 4096;
const OUTBOUND_MAX_IN_FLIGHT_PER_SERVICE = 64;
const RENDERED_AVATAR_COUNT = 120;
class DesktopOutboundHTTPCapacityError extends Error {
constructor(limit) {
super(`Desktop outbound HTTP exceeded its ${limit} limit`);
this.name = 'DesktopOutboundHTTPCapacityError';
}
}
const PARKED_MEDIA_BODY_COUNT = 200;
const RESOURCE_HEADERS_DEADLINE_MS = 30_000;
function tick() {
return new Promise((resolve) => {
@@ -28,33 +20,6 @@ function tick() {
});
}
function createOutboundHTTP() {
const state = {inFlight: 0, peakInFlight: 0, started: 0, pending: []};
const outboundHTTP = {
request: (request) => {
if (state.inFlight >= OUTBOUND_MAX_IN_FLIGHT_PER_SERVICE) {
throw new DesktopOutboundHTTPCapacityError(`in-flight request for ${request.serviceName}`);
}
state.inFlight += 1;
state.started += 1;
state.peakInFlight = Math.max(state.peakInFlight, state.inFlight);
return new Promise((resolve) => {
state.pending.push(() => {
state.inFlight -= 1;
resolve({
headers: {'content-type': 'image/webp'},
message: Readable.from([Buffer.from('avatar-bytes')]),
status: 200,
statusText: 'OK',
url: new URL(request.url),
});
});
});
},
};
return {outboundHTTP, state};
}
function avatarRequest(index, signal) {
return {
targetURL: `https://media.fluxer.app/avatars/${index}.webp`,
@@ -68,131 +33,17 @@ function avatarRequest(index, signal) {
};
}
async function pump(state, work) {
let finished = false;
const result = work.finally(() => {
finished = true;
});
result.catch(() => {});
while (!finished) {
for (const settle of state.pending.splice(0)) {
settle();
}
await tick();
}
return await result;
}
function readAvatar(client, index, signal) {
return client.fetch(avatarRequest(index, signal)).then(async (response) => ({
status: response.status,
text: await response.text(),
}));
}
describe('the local app proxy bounds its own concurrency instead of overrunning outbound HTTP', () => {
test('every avatar of a fully rendered chat resolves rather than tripping the outbound capacity cap', async () => {
const {outboundHTTP, state} = createOutboundHTTP();
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const controller = new AbortController();
const fetches = [];
for (let index = 0; index < RENDERED_AVATAR_COUNT; index += 1) {
fetches.push(readAvatar(client, index, controller.signal));
}
const responses = await pump(state, Promise.all(fetches));
assert.equal(responses.length, RENDERED_AVATAR_COUNT);
assert.deepEqual(new Set(responses.map((response) => response.status)), new Set([200]));
assert.equal(state.started, RENDERED_AVATAR_COUNT);
assert.deepEqual(
responses.map((response) => response.text),
new Array(RENDERED_AVATAR_COUNT).fill('avatar-bytes'),
);
});
test('no more requests are in flight at any instant than the proxy concurrency limit', async () => {
const {outboundHTTP, state} = createOutboundHTTP();
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const controller = new AbortController();
const fetches = [];
for (let index = 0; index < RENDERED_AVATAR_COUNT; index += 1) {
fetches.push(readAvatar(client, index, controller.signal));
}
await tick();
const peakBeforeAnyCompletion = state.peakInFlight;
await pump(state, Promise.all(fetches));
assert.equal(peakBeforeAnyCompletion, PROXY_MAX_CONCURRENT_REQUESTS);
assert.equal(state.peakInFlight, PROXY_MAX_CONCURRENT_REQUESTS);
});
test('an aborted waiter rejects with the abort reason and leaves its queue slot to the next waiter', async () => {
const {outboundHTTP, state} = createOutboundHTTP();
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const held = new AbortController();
const holding = [];
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS; index += 1) {
holding.push(readAvatar(client, index, held.signal));
}
await tick();
assert.equal(state.started, PROXY_MAX_CONCURRENT_REQUESTS);
const abandoned = new AbortController();
const abandonedFetch = client.fetch(avatarRequest(998, abandoned.signal));
const survivor = readAvatar(client, 999, held.signal);
const reason = new Error('the renderer navigated away');
abandoned.abort(reason);
await assert.rejects(abandonedFetch, (error) => error === reason);
assert.equal(state.started, PROXY_MAX_CONCURRENT_REQUESTS);
state.pending.shift()();
await tick();
await tick();
assert.equal(state.started, PROXY_MAX_CONCURRENT_REQUESTS + 1);
const settled = await pump(state, Promise.all([survivor, ...holding]));
assert.equal(settled[0].status, 200);
assert.equal(settled.length, PROXY_MAX_CONCURRENT_REQUESTS + 1);
});
test('the queue refuses a request past its bound rather than growing without limit', async () => {
const {outboundHTTP, state} = createOutboundHTTP();
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const controller = new AbortController();
const accepted = [];
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS + PROXY_MAX_QUEUED_REQUESTS; index += 1) {
accepted.push(readAvatar(client, index, controller.signal));
}
await assert.rejects(
client.fetch(avatarRequest(Number.MAX_SAFE_INTEGER, controller.signal)),
(error) => error.name === 'LocalAppProxyQueueOverflowError',
);
const settled = await pump(state, Promise.all(accepted));
assert.equal(settled.length, PROXY_MAX_CONCURRENT_REQUESTS + PROXY_MAX_QUEUED_REQUESTS);
});
});
const RESOURCE_HEADERS_DEADLINE_MS = 30_000;
function createSilentUpstream() {
function createUpstream({streamBodies}) {
const state = {started: [], aborted: 0};
const outboundHTTP = {
request: (request) =>
new Promise((resolve, reject) => {
const entry = {
url: request.url,
serviceName: request.serviceName,
answer: () =>
resolve({
headers: {'content-type': 'image/webp'},
message: Readable.from([Buffer.from('avatar-bytes')]),
message: streamBodies ? new PassThrough() : Readable.from([Buffer.from('avatar-bytes')]),
status: 200,
statusText: 'OK',
url: new URL(request.url),
@@ -212,80 +63,64 @@ function createSilentUpstream() {
return {outboundHTTP, state};
}
function apiRequest(signal) {
return {...avatarRequest(0, signal), targetURL: 'https://api.fluxer.app/v1/users/@me'};
}
describe('the local app proxy never queues a request behind another', () => {
test('every avatar of a fully rendered chat starts at once', async () => {
const {outboundHTTP, state} = createUpstream({streamBodies: false});
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const renderer = new AbortController();
describe('media that a slow or silent self-host never answers cannot wedge the local app proxy', () => {
test('avatars dropped while their upstream sends no headers free their permits at the headers deadline', async () => {
const fetches = [];
for (let index = 0; index < RENDERED_AVATAR_COUNT; index += 1) {
fetches.push(client.fetch(avatarRequest(index, renderer.signal)));
}
await tick();
assert.equal(state.started.length, RENDERED_AVATAR_COUNT);
for (const entry of state.started) {
entry.answer();
}
const texts = await Promise.all(fetches.map(async (fetch) => await (await fetch).text()));
assert.deepEqual(texts, new Array(RENDERED_AVATAR_COUNT).fill('avatar-bytes'));
});
test('a media request starts at once while earlier response bodies sit unread', async () => {
const {outboundHTTP, state} = createUpstream({streamBodies: true});
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const renderer = new AbortController();
for (let index = 0; index < PARKED_MEDIA_BODY_COUNT; index += 1) {
const fetch = client.fetch(avatarRequest(index, renderer.signal));
await tick();
state.started.at(-1).answer();
assert.equal((await fetch).status, 200);
}
const next = client.fetch(avatarRequest(PARKED_MEDIA_BODY_COUNT, renderer.signal));
await tick();
assert.equal(state.started.length, PARKED_MEDIA_BODY_COUNT + 1);
state.started.at(-1).answer();
assert.equal((await next).status, 200);
renderer.abort();
});
});
describe('media that a slow or silent self-host never answers is cut off', () => {
test('a silent upstream is aborted at the headers deadline', async () => {
mock.timers.enable({apis: ['setTimeout']});
try {
const {outboundHTTP, state} = createSilentUpstream();
const {outboundHTTP, state} = createUpstream({streamBodies: false});
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const renderer = new AbortController();
const dropped = [];
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS; index += 1) {
const fetch = client.fetch(avatarRequest(index, renderer.signal));
fetch.catch(() => {});
dropped.push(fetch);
}
const silent = client.fetch(avatarRequest(0, renderer.signal));
silent.catch(() => {});
await tick();
const fresh = client.fetch(avatarRequest(1000, renderer.signal));
await tick();
assert.equal(state.started.length, PROXY_MAX_CONCURRENT_REQUESTS);
mock.timers.tick(RESOURCE_HEADERS_DEADLINE_MS);
await Promise.allSettled(dropped);
await tick();
assert.equal(state.aborted, PROXY_MAX_CONCURRENT_REQUESTS);
assert.equal(state.started.length, PROXY_MAX_CONCURRENT_REQUESTS + 1);
state.started.at(-1).answer();
const response = await fresh;
assert.equal(response.status, 200);
assert.equal(await response.text(), 'avatar-bytes');
await assert.rejects(silent, (error) => error.name === 'LocalAppProxyResponseHeadersDeadlineError');
assert.equal(state.aborted, 1);
} finally {
mock.timers.reset();
}
});
test('API requests keep flowing while every media permit is held by a silent upstream', async () => {
const {outboundHTTP, state} = createSilentUpstream();
const media = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const api = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.API});
const renderer = new AbortController();
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS * 2; index += 1) {
media.fetch(avatarRequest(index, renderer.signal)).catch(() => {});
}
await tick();
const apiCall = api.fetch(apiRequest(renderer.signal));
await tick();
const started = state.started.at(-1);
assert.equal(started.url, 'https://api.fluxer.app/v1/users/@me');
assert.equal(started.serviceName, LocalAppProxyTraffic.API);
started.answer();
assert.equal((await apiCall).status, 200);
renderer.abort();
});
test('the newest queued media request is admitted first so what is on screen loads before what scrolled away', async () => {
const {outboundHTTP, state} = createSilentUpstream();
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
const renderer = new AbortController();
const fetches = [];
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS + 3; index += 1) {
const fetch = client.fetch(avatarRequest(index, renderer.signal));
fetch.catch(() => {});
fetches.push(fetch);
}
await tick();
state.started[0].answer();
await (await fetches[0]).text();
await tick();
assert.equal(
state.started.at(-1).url,
`https://media.fluxer.app/avatars/${PROXY_MAX_CONCURRENT_REQUESTS + 2}.webp`,
);
renderer.abort();
});
});
@@ -15,7 +15,6 @@ const INSTANCE_RESPONSE_MAX_BYTES = 1024 * 1024;
const INSTANCE_RESPONSE_MAX_CHUNKS = 1024;
const INSTANCE_FETCH_TIMEOUT_MS = 15_000;
const SELECTED_INSTANCE_SERVICE_NAME = 'desktop_selected_instance';
const INSTANCE_RESPONSE_DESCRIPTION = 'Instance response';
const RESET_CONTENT_STATUS = 205;
@@ -80,7 +79,6 @@ export class DesktopSelectedInstanceClient {
headers: request.headers ?? null,
method,
originTrust: DesktopOriginTrust.BOUND,
serviceName: SELECTED_INSTANCE_SERVICE_NAME,
signal: request.signal ?? null,
timeoutMs,
url: request.url,
@@ -514,6 +514,8 @@ Every uploaded file lands in an S3-compatible object store, which the stack prov
Default `http://localhost:3900`. The S3 API address. `media-proxy` defaults to empty instead. Compose forwards it from `.env` with a default of `http://seaweedfs:8333`, the bundled store.
An endpoint with a bucket in its path, such as `https://s3.example.com/my-bucket` with `FLUXER_S3_FORCE_PATH_STYLE=true`, keeps every Fluxer bucket inside that one bucket. Each bucket name becomes a key prefix there, so uploads land under `fluxer-uploads/` in `my-bucket`.
#### `FLUXER_S3_PUBLIC_ENDPOINT`
No default. The host substituted into presigned URLs, which are the only place it appears. Compose forwards it from `.env`, and uses `FLUXER_S3_ENDPOINT` when it is unset, except on `app-proxy`, which gets only a value `.env` sets and adds it to its Content Security Policy. `admin` reads it to find the origin that report downloads are presigned against. See `FLUXER_S3_BUCKET_REPORTS`.