feat(media-proxy): sign attachment URLs and gate origins (#2830)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-18 15:57:32 +02:00
1 parent 025c01ab13
commit 522cf08e61
108 files changed
+10148 -441

No files matched your search

@@ -4,6 +4,7 @@ import {ME} from '@fluxer/constants/src/AppConstants';
export const Endpoints = {
INSTANCE: '/instance',
ATTACHMENTS_REFRESH_URLS: '/attachments/refresh-urls',
AUTH_LOGIN: '/auth/login',
AUTH_LOGIN_MFA_TOTP: '/auth/login/mfa/totp',
AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS: '/auth/login/mfa/webauthn/authentication-options',
@@ -4,6 +4,7 @@ import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimated
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import styles from '@app/features/channel/components/AutocompleteEmoji.module.css';
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {MusicNoteIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
@@ -32,6 +33,7 @@ export const AutocompleteMemePreview = observer(({meme}: {meme: FavoriteMeme}) =
const isVideoMeme = contentType.startsWith('video/');
const isAnimatedImageMeme = !isVideoMeme && !isAudioMeme && contentType.includes('gif');
const motionAllowed = useShouldAnimate({kind: 'gif', isAnimated: isVideoMeme || isAnimatedImageMeme});
const memeUrl = AttachmentUrlRefresher.fresh(meme.url, {refreshUnsigned: true});
if (isAudioMeme) {
return (
<div className={styles.audioIconWrapper} data-flx="channel.autocomplete-meme-preview.audio-icon-wrapper">
@@ -44,16 +46,14 @@ export const AutocompleteMemePreview = observer(({meme}: {meme: FavoriteMeme}) =
);
}
if (isVideoMeme && motionAllowed) {
return (
<AutocompleteMemeVideo src={meme.url} data-flx="channel.autocomplete-meme-preview.autocomplete-meme-video" />
);
return <AutocompleteMemeVideo src={memeUrl} data-flx="channel.autocomplete-meme-preview.autocomplete-meme-video" />;
}
const needsStillFrame = (isVideoMeme || isAnimatedImageMeme) && !motionAllowed;
return (
<img
draggable={false}
className={styles.memeIcon}
src={needsStillFrame ? buildStaticGifPreviewURL(meme.url) : meme.url}
src={needsStillFrame ? buildStaticGifPreviewURL(memeUrl) : memeUrl}
alt={meme.name}
aria-hidden={true}
data-flx="channel.autocomplete-meme-preview.meme-icon"
@@ -2,6 +2,7 @@
import {splitMediaAndFileAttachments} from '@app/features/channel/components/MessageAttachmentUtils';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {mapAttachmentsWithExpiry} from '@app/features/messaging/utils/AttachmentExpiryUtils';
import UserSettings from '@app/features/user/state/UserSettings';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
@@ -13,11 +14,25 @@ interface AttachmentRenderingState {
shouldUseMosaic: boolean;
}
function freshAttachmentUrl(url: string | null): string | null {
return url === null ? null : AttachmentUrlRefresher.fresh(url);
}
export function withFreshAttachmentUrls(attachment: MessageAttachment): MessageAttachment {
const url = freshAttachmentUrl(attachment.url);
const proxyUrl = freshAttachmentUrl(attachment.proxy_url);
if (url === attachment.url && proxyUrl === attachment.proxy_url) return attachment;
return {...attachment, url, proxy_url: proxyUrl};
}
export const getAttachmentRenderingState = (
snapshotAttachments?: ReadonlyArray<MessageAttachment> | null,
): AttachmentRenderingState => {
const attachments = snapshotAttachments ?? [];
const expiryApplied = mapAttachmentsWithExpiry(attachments, DeveloperOptions.mockAttachmentStates);
const expiryApplied = mapAttachmentsWithExpiry(attachments, DeveloperOptions.mockAttachmentStates).map((entry) => ({
isExpired: entry.isExpired,
attachment: withFreshAttachmentUrls(entry.attachment),
}));
const enrichedAttachments = expiryApplied.map((entry) => entry.attachment);
const activeAttachments = expiryApplied.filter((entry) => !entry.isExpired).map((entry) => entry.attachment);
const {mediaAttachments} = splitMediaAndFileAttachments(activeAttachments);
@@ -20,6 +20,7 @@ import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
import {AUDIO_DESCRIPTOR, GIFS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {DeleteIcon, EditIcon} from '@app/features/ui/action_menu/ContextMenuIcons';
@@ -428,7 +429,8 @@ const GridItem = observer(
const isAudio = contentType.startsWith('audio/');
const isGifImage = !isVideo && contentType.toLowerCase().includes('gif');
const shouldAnimateGif = useShouldAnimate({kind: 'gif', isAnimated: !isAudio && (isVideo || isGifImage)});
const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(url) : url;
const memeUrl = AttachmentUrlRefresher.fresh(url, {refreshUnsigned: true});
const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(memeUrl) : memeUrl;
const videoPlaybackAllowed = useAnimatedMediaVideoPlayback(videoRef, {
enabled: isVisible && !isAudio && isVideo,
shouldPlay: shouldAnimateGif,
@@ -471,7 +473,7 @@ const GridItem = observer(
disablePictureInPicture={true}
disableRemotePlayback={true}
preload={shouldAnimateGif ? 'auto' : 'metadata'}
src={url}
src={memeUrl}
data-flx="channel.mobile-memes-picker.grid-item.gif"
/>
)}
@@ -26,6 +26,7 @@ import Channels from '@app/features/channel/state/Channels';
import Guilds from '@app/features/guild/state/Guilds';
import {SUPPRESS_EMBEDS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {getEmbedMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
import {buildAnimatedImageProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {buildMessageEmbedCopyText} from '@app/features/messaging/utils/MessageCopyTextUtils';
@@ -39,6 +40,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {MessageAttachmentFlags, MessageEmbedTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import type {EmbedMedia, MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {useLingui} from '@lingui/react/macro';
import {XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
@@ -47,7 +49,29 @@ import type React from 'react';
import {type FC, useCallback, useMemo} from 'react';
const mediaFocusRingClass = messageStyles.mediaFocusRing;
export const Embed: FC<EmbedProps> = observer(({embed, message, embedIndex, contextualEmbeds, onDelete, isPreview}) => {
function withFreshEmbedMediaUrls(media: EmbedMedia | undefined): EmbedMedia | undefined {
if (!media) return media;
const url = AttachmentUrlRefresher.fresh(media.url);
const proxyUrl = media.proxy_url === undefined ? undefined : AttachmentUrlRefresher.fresh(media.proxy_url);
if (url === media.url && proxyUrl === media.proxy_url) return media;
return {...media, url, proxy_url: proxyUrl};
}
function withFreshEmbedUrls(embed: MessageEmbed): MessageEmbed {
const image = withFreshEmbedMediaUrls(embed.image);
const thumbnail = withFreshEmbedMediaUrls(embed.thumbnail);
const video = withFreshEmbedMediaUrls(embed.video);
const audio = withFreshEmbedMediaUrls(embed.audio);
if (image === embed.image && thumbnail === embed.thumbnail && video === embed.video && audio === embed.audio) {
return embed;
}
return {...embed, image, thumbnail, video, audio};
}
export const Embed: FC<EmbedProps> = observer((props: EmbedProps) => {
const {message, embedIndex, contextualEmbeds, onDelete, isPreview} = props;
const embed = withFreshEmbedUrls(props.embed);
const {i18n} = useLingui();
const {enabled: isMobile} = MobileLayout;
const channel = Channels.getChannel(message.channelId);
@@ -9,6 +9,10 @@ import {CsvAttachmentTablePanel} from '@app/features/channel/components/embeds/a
import {TextualAttachmentCodePanel} from '@app/features/channel/components/embeds/attachments/TextualAttachmentCodePanel';
import styles from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreview.module.css';
import {TextualAttachmentPreviewBottomSheet} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewBottomSheet';
import {
fetchTextualPreviewText,
PreviewSizeLimitError,
} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewFetch';
import {TextualAttachmentPreviewFooter} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewFooter';
import {TextualAttachmentPreviewModal} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewModal';
import {
@@ -45,56 +49,6 @@ import {type MouseEvent, useCallback, useEffect, useMemo, useState} from 'react'
const logger = new Logger('TextualAttachmentPreview');
class PreviewSizeLimitError extends Error {
constructor() {
super('Attachment preview exceeds the size limit');
this.name = 'PreviewSizeLimitError';
}
}
async function readPreviewText(response: Response): Promise<string> {
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
const parsedContentLength = Number(contentLength);
if (Number.isFinite(parsedContentLength) && parsedContentLength > TEXT_PREVIEW_MAX_BYTES) {
throw new PreviewSizeLimitError();
}
}
const body = response.body;
if (!body) {
throw new Error('Attachment preview response has no readable body');
}
const reader = body.getReader();
const chunks: Array<Uint8Array> = [];
let totalBytes = 0;
try {
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
totalBytes += value.byteLength;
if (totalBytes > TEXT_PREVIEW_MAX_BYTES) {
await reader.cancel().catch(() => undefined);
throw new PreviewSizeLimitError();
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
export const TextualAttachmentPreview = observer(function TextualAttachmentPreview({
attachment,
spoilerHidden = false,
@@ -146,13 +100,7 @@ export const TextualAttachmentPreview = observer(function TextualAttachmentPrevi
setStatus('loading');
setPreviewError(null);
const controller = new AbortController();
fetch(attachment.url, {signal: controller.signal})
.then((response) => {
if (!response.ok) {
throw new Error(`Attachment preview request failed: ${response.status} ${response.statusText}`);
}
return readPreviewText(response);
})
fetchTextualPreviewText(attachment.url, controller.signal)
.then((value) => {
if (controller.signal.aborted) {
return;
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {TEXT_PREVIEW_MAX_BYTES} from '@app/features/messaging/utils/AttachmentPreviewUtils';
const SIGNATURE_REFUSAL_STATUSES = new Set([401, 403, 404, 410]);
export class PreviewSizeLimitError extends Error {
constructor() {
super('Attachment preview exceeds the size limit');
this.name = 'PreviewSizeLimitError';
}
}
export function isAttachmentSignatureRefusal(status: number): boolean {
return SIGNATURE_REFUSAL_STATUSES.has(status);
}
export async function readPreviewText(response: Response): Promise<string> {
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
const parsedContentLength = Number(contentLength);
if (Number.isFinite(parsedContentLength) && parsedContentLength > TEXT_PREVIEW_MAX_BYTES) {
throw new PreviewSizeLimitError();
}
}
const body = response.body;
if (!body) {
throw new Error('Attachment preview response has no readable body');
}
const reader = body.getReader();
const chunks: Array<Uint8Array> = [];
let totalBytes = 0;
try {
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
totalBytes += value.byteLength;
if (totalBytes > TEXT_PREVIEW_MAX_BYTES) {
await reader.cancel().catch(() => undefined);
throw new PreviewSizeLimitError();
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function requestFailed(response: Response): Error {
return new Error(`Attachment preview request failed: ${response.status} ${response.statusText}`);
}
export async function fetchTextualPreviewText(url: string, signal: AbortSignal): Promise<string> {
const target = await AttachmentUrlRefresher.refresh(url);
const response = await fetch(target, {signal});
if (response.ok) {
return readPreviewText(response);
}
if (!isAttachmentSignatureRefusal(response.status)) {
throw requestFailed(response);
}
const refreshed = await AttachmentUrlRefresher.refresh(url, {force: true});
if (refreshed === target) {
throw requestFailed(response);
}
const retried = await fetch(refreshed, {signal});
if (!retried.ok) {
throw requestFailed(retried);
}
return readPreviewText(retried);
}
@@ -19,6 +19,7 @@ import {safePause, safePlay} from '@app/features/channel/components/GifVideoPool
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import type {Channel} from '@app/features/channel/models/Channel';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
@@ -389,6 +390,7 @@ export const EmbedGifv: FC<
const messageViewContext = useMaybeMessageViewContext();
const mediaCalculator = useEmbedMediaCalculator();
const videoRef = useRef<HTMLVideoElement>(null);
const handleVideoError = useAttachmentRefreshOnError(videoProxyURL);
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement);
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
@@ -632,6 +634,7 @@ export const EmbedGifv: FC<
height={dimensions.height}
tabIndex={-1}
aria-label={i18n._(ANIMATED_GIF_VIDEO_DESCRIPTOR)}
onError={handleVideoError}
data-embed-media="gifv"
data-flx="channel.embeds.media.embed-gifv.video-element"
/>
@@ -6,6 +6,7 @@ import styles from '@app/features/channel/components/embeds/media/VoiceMessagePl
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {PAUSE_DESCRIPTOR, PLAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -119,6 +120,7 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
const {i18n} = useLingui();
const messageViewContext = useMaybeMessageViewContext();
const effectiveSrc = buildMediaProxyURL(src);
const handleMediaError = useAttachmentRefreshOnError(effectiveSrc);
const [hasStarted, setHasStarted] = useState(false);
const [wantsMetadata, setWantsMetadata] = useState(false);
const [prePlayCurrentTime, setPrePlayCurrentTime] = useState(0);
@@ -324,6 +326,7 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={hasStarted || wantsMetadata ? effectiveSrc : undefined}
preload={wantsMetadata ? 'metadata' : 'none'}
onError={handleMediaError}
data-flx="channel.embeds.media.voice-message-player.audio"
>
<track kind="captions" data-flx="channel.embeds.media.voice-message-player.track" />
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys';
export interface FavoriteGifMediaFormat {
@@ -107,16 +108,36 @@ export function pickCanonicalPreviewFormat(
export const STORED_PREVIEW_DEVICE_PIXEL_RATIO = 2;
export function stripFavoriteGifEntrySignatures(entry: FavoriteGifEntry): FavoriteGifEntry {
const url = stripAttachmentSignature(entry.url);
const proxyUrl = stripAttachmentSignature(entry.proxy_url);
const media: Record<string, FavoriteGifMediaFormat> = {};
let mediaChanged = false;
for (const [key, format] of Object.entries(entry.media)) {
const src = stripAttachmentSignature(format.src);
const proxySrc = stripAttachmentSignature(format.proxy_src);
if (src === format.src && proxySrc === format.proxy_src) {
media[key] = format;
continue;
}
mediaChanged = true;
media[key] = {...format, src, proxy_src: proxySrc};
}
if (url === entry.url && proxyUrl === entry.proxy_url && !mediaChanged) return entry;
return {...entry, url, proxy_url: proxyUrl, media};
}
export function slimFavoriteGifEntry(entry: FavoriteGifEntry): FavoriteGifEntry {
const best = pickBestPreviewFormat(entry.media, 'any', {
const stripped = stripFavoriteGifEntrySignatures(entry);
const best = pickBestPreviewFormat(stripped.media, 'any', {
cssWidth: PREVIEW_TILE_CSS_WIDTH,
devicePixelRatio: STORED_PREVIEW_DEVICE_PIXEL_RATIO,
});
if (best == null) {
return Object.keys(entry.media).length === 0 ? entry : {...entry, media: {}};
return Object.keys(stripped.media).length === 0 ? stripped : {...stripped, media: {}};
}
return {
...entry,
...stripped,
proxy_url: best.format.proxy_src,
width: best.format.width,
height: best.format.height,
@@ -16,6 +16,7 @@ import FavoriteGif from '@app/features/expressions/state/FavoriteGif';
import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
import * as GifSlugUtils from '@app/features/expressions/utils/GifSlugUtils';
import {FAVORITES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -57,6 +58,7 @@ export const GifPickerGrid = observer(
const favoriteGifs = FavoriteGif.favoriteGifs;
const favoriteGifsVersion = favoriteGifs.length;
const useSavedMediaForGifFavorites = FavoriteGif.saveGifFavoritesAsSavedMedia;
const attachmentUrlRevision = AttachmentUrlRefresher.revision;
const data: Array<GifPickerGridItemData> = useMemo(() => {
return buildGifPickerGridData({
surface: store.isShowingFavorites ? 'favorites' : store.isShowingFeatured ? 'featured' : 'results',
@@ -88,6 +90,7 @@ export const GifPickerGrid = observer(
selectGif,
store.featuredFavoritePreviewSeed,
i18n.locale,
attachmentUrlRevision,
]);
const itemKeys = useMemo(() => data.filter((item) => item.type !== 'skeleton').map((item) => item.key), [data]);
const itemByKey = useMemo(() => new Map(data.map((item) => [item.key, item])), [data]);
@@ -8,6 +8,7 @@ import {
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
import type {Gif, GifFeatured} from '@app/features/expressions/commands/GifCommands';
import * as GifSlugUtils from '@app/features/expressions/utils/GifSlugUtils';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
const CATEGORY_TILE_WIDTH = 200;
const CATEGORY_TILE_HEIGHT = 96;
@@ -34,6 +35,10 @@ export interface BuildGifPickerGridDataInput {
trendingTitle: string;
}
function freshStoredUrl(url: string): string {
return AttachmentUrlRefresher.fresh(url, {refreshUnsigned: true});
}
export function buildSkeletonGifPickerItems(count: number): Array<GifPickerGridItemData> {
return Array.from({length: count}, (_, i) => ({
type: 'skeleton',
@@ -68,9 +73,9 @@ function buildFavoriteGifItems(
for (let index = favoriteGifs.length - 1; index >= 0; index -= 1) {
const entry = favoriteGifs[index];
const best = pickBestPreviewFormat(entry.media);
const fallbackSrc = GifSlugUtils.isUsableMediaSource(entry.proxy_url) ? entry.proxy_url : '';
const previewSrc = best?.format.src ?? fallbackSrc;
const previewProxySrc = best?.format.proxy_src ?? fallbackSrc;
const fallbackSrc = GifSlugUtils.isUsableMediaSource(entry.proxy_url) ? freshStoredUrl(entry.proxy_url) : '';
const previewSrc = best ? freshStoredUrl(best.format.src) : fallbackSrc;
const previewProxySrc = best ? freshStoredUrl(best.format.proxy_src) : fallbackSrc;
const previewWidth = best?.format.width ?? entry.width;
const previewHeight = best?.format.height ?? entry.height;
const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type;
@@ -107,14 +112,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media);
const favoriteMemeCandidate =
gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null;
const favoriteMemePreview = favoriteMemeCandidate?.url ?? '';
const favoriteMemePreview = favoriteMemeCandidate ? freshStoredUrl(favoriteMemeCandidate.url) : '';
const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0;
const favoriteTilePreview = usesFavoriteMemePreview
? favoriteMemePreview
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
: freshStoredUrl(
favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '',
);
const favoriteTileProxyPreview = usesFavoriteMemePreview
? favoriteTilePreview
: favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
: freshStoredUrl(
favoriteGifPreview?.format.proxy_src ||
favoriteGifPreviewEntry?.proxy_url ||
favoriteGifPreviewEntry?.url ||
'',
);
const favoriteTileContentType = (() => {
if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? '';
if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key);
@@ -13,6 +13,7 @@ import * as FavoriteMemeCommands from '@app/features/expressions/commands/Favori
import {EditFavoriteMemeModal} from '@app/features/expressions/components/modals/EditFavoriteMemeModal';
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {buildMediaProxyURL, buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
@@ -113,10 +114,14 @@ export const MemeGridItem = observer(
isHovering: isVideoPreviewActive,
});
const shouldRenderVideoPreview = !isAudio && isVideo && isVideoPreviewActive && shouldAnimateGif;
const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(meme.url) : meme.url;
const memeUrl = AttachmentUrlRefresher.fresh(meme.url, {refreshUnsigned: true});
const thumbnailSrc =
isGifImage && !shouldAnimateGif
? AttachmentUrlRefresher.fresh(buildStaticGifPreviewURL(meme.url), {refreshUnsigned: true})
: memeUrl;
const videoPreviewStartTime = meme.duration && meme.duration > 0 ? meme.duration / 2 : null;
usePooledVideo({
src: shouldRenderVideoPreview ? meme.url : null,
src: shouldRenderVideoPreview ? memeUrl : null,
containerRef: videoContainerRef,
videoPool,
autoPlay: shouldRenderVideoPreview,
@@ -219,7 +224,7 @@ export const MemeGridItem = observer(
data-flx="channel.pickers.memes.meme-grid-item.div--2"
>
<VideoPoster
src={meme.url}
src={memeUrl}
placeholder={meme.placeholder}
data-flx="channel.pickers.memes.meme-grid-item.video-poster"
/>
@@ -4,7 +4,9 @@ import {
type FavoriteGifEntry,
type FavoriteGifMediaFormat,
slimFavoriteGifEntry,
stripFavoriteGifEntrySignatures,
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
import {makeSyncedField} from '@app/features/user/state/SyncedField';
import {FAVORITE_GIF_MAX_ENCODED_BYTES} from '@app/features/user/state/SyncedFieldBudget';
import type {FavoriteGifMediaFormat as FavoriteGifMediaFormatProto} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
@@ -91,25 +93,29 @@ class FavoriteGif {
}
hasUrl(url: string): boolean {
return this.favoriteGifs.some((entry) => entry.url === url);
const target = stripAttachmentSignature(url);
return this.favoriteGifs.some((entry) => stripAttachmentSignature(entry.url) === target);
}
findByUrl(url: string): FavoriteGifEntry | null {
return this.favoriteGifs.find((entry) => entry.url === url) ?? null;
const target = stripAttachmentSignature(url);
return this.favoriteGifs.find((entry) => stripAttachmentSignature(entry.url) === target) ?? null;
}
addEntry(entry: FavoriteGifEntry): void {
if (this.hasUrl(entry.url)) return;
this.favoriteGifs = [...this.favoriteGifs, entry];
const stored = stripFavoriteGifEntrySignatures(entry);
if (this.hasUrl(stored.url)) return;
this.favoriteGifs = [...this.favoriteGifs, stored];
}
removeByUrl(url: string): void {
if (!this.hasUrl(url)) return;
this.favoriteGifs = this.favoriteGifs.filter((entry) => entry.url !== url);
const target = stripAttachmentSignature(url);
this.favoriteGifs = this.favoriteGifs.filter((entry) => stripAttachmentSignature(entry.url) !== target);
}
replaceAll(entries: ReadonlyArray<FavoriteGifEntry>): void {
this.favoriteGifs = [...entries];
this.favoriteGifs = entries.map(stripFavoriteGifEntrySignatures);
}
setSaveGifFavoritesAsSavedMedia(value: boolean): void {
@@ -28,6 +28,7 @@ import {selectGuildActivationTarget} from '@app/features/gateway/transport/Guild
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import Messages from '@app/features/messaging/state/MessagingMessages';
import Navigation from '@app/features/navigation/state/Navigation';
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
@@ -587,6 +588,7 @@ class GatewayConnection {
Presence.handleSessionInvalidated();
Messages.handleSessionInvalidated();
FavoriteMemes.reset();
AttachmentUrlRefresher.reset();
GuildMatureContentAgree.reset();
Initialization.reset();
MemberSearch.handleLogout();
@@ -28,6 +28,7 @@ import {
type RendererProps,
} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {ExternalLinkWarningModal} from '@app/features/messaging/components/modals/ExternalLinkWarningModal';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import type {LinkNode} from '@app/features/messaging/utils/markdown/parser/Nodes';
@@ -889,12 +890,16 @@ export const LinkRenderer = observer(function LinkRenderer({
logger.warn('Invalid URL in link:', url);
}
}
const href = AttachmentUrlRefresher.fresh(url);
const warmAttachmentUrl = () => AttachmentUrlRefresher.warm(url);
return (
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--2">
<a
href={url}
href={href}
target={isInternal ? undefined : '_blank'}
rel={isInternal ? undefined : 'noopener noreferrer'}
onPointerEnter={warmAttachmentUrl}
onFocus={warmAttachmentUrl}
onClick={(e) => {
e.stopPropagation();
if (handleClick) {
@@ -18,6 +18,7 @@ import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modal
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils';
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils';
@@ -273,13 +274,20 @@ const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(funct
/>
);
});
function withFreshViewerItemUrls(item: MediaViewerItem): MediaViewerItem {
if (!item) return item;
const src = AttachmentUrlRefresher.fresh(item.src);
const originalSrc = AttachmentUrlRefresher.fresh(item.originalSrc);
if (src === item.src && originalSrc === item.originalSrc) return item;
return {...item, src, originalSrc};
}
const MediaViewerModalComponent: FC = observer(() => {
const {i18n} = useLingui();
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel, allowAttachmentDelete} =
MediaViewer;
const {enabled: isMobile} = MobileLayout;
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex];
const currentItem = withFreshViewerItemUrls(items[currentIndex]);
const currentGifvIsActualGif = currentItem != null && isGifvRenderedAsImage(currentItem);
useBottomSheetBackHandler(isOpen, MediaViewerCommands.closeMediaViewer);
useEffect(() => {
@@ -296,7 +304,10 @@ const MediaViewerModalComponent: FC = observer(() => {
count === 2 ? [(currentIndex + 1) % count] : [(currentIndex - 1 + count) % count, (currentIndex + 1) % count];
const unpinCallbacks = adjacentIndices.map((index) => {
const item = items[index];
if (!item || !isViewerImageItem(item) || item.src.startsWith('blob:')) return () => {};
if (!item) return () => {};
AttachmentUrlRefresher.warm(item.src);
AttachmentUrlRefresher.warm(item.originalSrc);
if (!isViewerImageItem(item) || item.src.startsWith('blob:')) return () => {};
return ImageCacheUtils.pinImage(buildViewerMediaURL(item));
});
return () => {
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {useCallback, useRef} from 'react';
export function useAttachmentRefreshOnError(src: string | undefined): () => void {
const refreshedSourceRef = useRef<string | null>(null);
return useCallback(() => {
if (src === undefined || src.length === 0) return;
if (refreshedSourceRef.current === src) return;
refreshedSourceRef.current = src;
void AttachmentUrlRefresher.refresh(src, {force: true});
}, [src]);
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils';
import {type SyntheticEvent, useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
@@ -123,6 +124,7 @@ export function useMediaLoading(
const {enabled = true} = options;
const shouldForcePlaceholder = DeveloperOptions.forceRenderPlaceholders || DeveloperOptions.forceMediaLoading;
const mediaElementRef = useRef<MediaLoadingElement | null>(null);
const refreshedSourceRef = useRef<string | null>(null);
const loadEnabled = enabled && src.length > 0 && !shouldForcePlaceholder;
const sourceIdentity = useMemo<MediaLoadingSourceIdentity>(() => ({src, loadEnabled}), [loadEnabled, src]);
const committedSourceIdentityRef = useRef(sourceIdentity);
@@ -184,6 +186,10 @@ export function useMediaLoading(
if (mediaElementRef.current !== element) return;
if (!mediaElementMatchesSource(element, src)) return;
ImageCacheUtils.forgetImage(src);
if (refreshedSourceRef.current !== src) {
refreshedSourceRef.current = src;
void AttachmentUrlRefresher.refresh(src, {force: true});
}
setSourceState((currentState) => {
if (currentState.src === src && currentState.loadEnabled === loadEnabled && currentState.loaded) {
return {...currentState, cached: false};
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
import {LRUCache} from 'lru-cache';
import {createContext, useCallback, useContext, useEffect, useState} from 'react';
@@ -23,6 +24,11 @@ export function resolveObserverRoot(resolve: ScrollSurfaceResolver, element: Ele
return surface;
}
export function resolveViewportKey(rememberKey: string | null | undefined): string | null {
if (!rememberKey) return null;
return stripAttachmentSignature(rememberKey);
}
interface UseNearViewportOptions {
disabled?: boolean;
rememberKey?: string | null;
@@ -37,8 +43,9 @@ export function useNearViewport<T extends Element>({
threshold = 0,
}: UseNearViewportOptions = {}): {ref: (node: T | null) => void; isNearViewport: boolean} {
const resolveScrollSurface = useContext(NearViewportSurfaceContext);
const viewportKey = resolveViewportKey(rememberKey);
const loadImmediately = disabled || typeof IntersectionObserver === 'undefined';
const wasRemembered = rememberKey ? rememberedViewportKeys.has(rememberKey) : false;
const wasRemembered = viewportKey ? rememberedViewportKeys.has(viewportKey) : false;
const [element, setElement] = useState<T | null>(null);
const [isNearViewport, setIsNearViewport] = useState(loadImmediately || wasRemembered);
const ref = useCallback((node: T | null) => {
@@ -50,13 +57,13 @@ export function useNearViewport<T extends Element>({
}
}, [disabled]);
useEffect(() => {
if (!isNearViewport || !rememberKey) return;
if (!isNearViewport || !viewportKey) return;
if (disabled || typeof IntersectionObserver === 'undefined') return;
rememberedViewportKeys.set(rememberKey, true);
}, [disabled, isNearViewport, rememberKey]);
rememberedViewportKeys.set(viewportKey, true);
}, [disabled, isNearViewport, viewportKey]);
useEffect(() => {
if (disabled || isNearViewport || !element) return undefined;
if (rememberKey && rememberedViewportKeys.has(rememberKey)) {
if (viewportKey && rememberedViewportKeys.has(viewportKey)) {
setIsNearViewport(true);
return undefined;
}
@@ -70,14 +77,14 @@ export function useNearViewport<T extends Element>({
(entry) => {
if (!entry.isIntersecting && entry.intersectionRatio <= 0) return;
stopObserving();
if (rememberKey) {
rememberedViewportKeys.set(rememberKey, true);
if (viewportKey) {
rememberedViewportKeys.set(viewportKey, true);
}
setIsNearViewport(true);
},
{root: resolveObserverRoot(resolveScrollSurface, element), rootMargin, threshold},
);
return stopObserving;
}, [disabled, element, isNearViewport, rememberKey, resolveScrollSurface, rootMargin, threshold]);
}, [disabled, element, isNearViewport, viewportKey, resolveScrollSurface, rootMargin, threshold]);
return {ref, isNearViewport};
}
@@ -0,0 +1,242 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {
ATTACHMENT_URL_REFRESH_MARGIN_MS,
applyAttachmentSignature,
attachmentCacheKey,
attachmentUrlNeedsRefresh,
isAttachmentCdnUrl,
readAttachmentSignatureFields,
readAttachmentUrlSignature,
stripAttachmentSignature,
} from '@app/features/messaging/utils/AttachmentCdnUrl';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {RefreshAttachmentUrlsResponse} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
import {makeAutoObservable, runInAction, untracked} from 'mobx';
const logger = new Logger('AttachmentUrlRefresher');
const FLUSH_WINDOW_MS = 16;
const MAX_URLS_PER_REQUEST = 50;
const UNSIGNED_RESULT_COOLDOWN_MS = 600_000;
const TRANSPORT_ERROR_COOLDOWN_MS = 30_000;
const RATE_LIMIT_FALLBACK_COOLDOWN_MS = 10_000;
type CacheEntry = {kind: 'signature'; fields: string; goodUntilMs: number} | {kind: 'blocked'; untilMs: number};
interface QueuedRequest {
url: string;
resolvers: Array<(fields: string | null) => void>;
}
class AttachmentUrlRefresher {
entries = new Map<string, CacheEntry>();
readonly queued = new Map<string, QueuedRequest>();
readonly inFlight = new Map<string, Promise<string | null>>();
flushTimer: ReturnType<typeof setTimeout> | null = null;
cooldownUntilMs = 0;
generation = 0;
revision = 0;
constructor() {
makeAutoObservable(
this,
{
queued: false,
inFlight: false,
flushTimer: false,
cooldownUntilMs: false,
generation: false,
},
{autoBind: true},
);
}
fresh(url: string, options: {refreshUnsigned?: boolean} = {}): string {
if (!url || !isAttachmentCdnUrl(url, RuntimeConfig.mediaEndpoint)) return url;
const signature = readAttachmentUrlSignature(url);
const nowMs = Date.now();
if (signature !== null && signature.expiresAtMs > nowMs + ATTACHMENT_URL_REFRESH_MARGIN_MS) return url;
const key = attachmentCacheKey(url);
if (key === null) return url;
const entry = this.entries.get(key);
if (entry?.kind === 'signature' && entry.goodUntilMs > nowMs) {
return applyAttachmentSignature(url, entry.fields);
}
if (signature === null && options.refreshUnsigned !== true) return url;
if (entry?.kind === 'blocked' && entry.untilMs > nowMs) return url;
this.enqueue(key, url, nowMs);
return url;
}
warm(url: string): void {
if (!url || !isAttachmentCdnUrl(url, RuntimeConfig.mediaEndpoint)) return;
const nowMs = Date.now();
if (!attachmentUrlNeedsRefresh(url, nowMs)) return;
const key = attachmentCacheKey(url);
if (key === null) return;
const entry = untracked(() => this.entries.get(key));
if (entry?.kind === 'signature' && entry.goodUntilMs > nowMs) return;
if (entry?.kind === 'blocked' && entry.untilMs > nowMs) return;
this.enqueue(key, url, nowMs);
}
async refresh(url: string, options: {force?: boolean} = {}): Promise<string> {
if (!url || !isAttachmentCdnUrl(url, RuntimeConfig.mediaEndpoint)) return url;
const key = attachmentCacheKey(url);
if (key === null) return url;
const nowMs = Date.now();
const entry = untracked(() => this.entries.get(key));
if (entry?.kind === 'signature' && entry.goodUntilMs > nowMs) {
return applyAttachmentSignature(url, entry.fields);
}
if (options.force !== true) {
if (entry?.kind === 'blocked' && entry.untilMs > nowMs) return url;
if (!attachmentUrlNeedsRefresh(url, nowMs)) return url;
}
const fields = await this.request(key, url, nowMs);
return fields === null ? url : applyAttachmentSignature(url, fields);
}
reset(): void {
this.generation += 1;
this.revision += 1;
this.entries = new Map();
for (const request of this.queued.values()) {
for (const resolve of request.resolvers) resolve(null);
}
this.queued.clear();
this.inFlight.clear();
this.cooldownUntilMs = 0;
if (this.flushTimer !== null) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
private forgetExpired(nowMs: number): void {
for (const [key, entry] of this.entries) {
const untilMs = entry.kind === 'signature' ? entry.goodUntilMs : entry.untilMs;
if (untilMs <= nowMs) this.entries.delete(key);
}
}
private enqueue(key: string, url: string, nowMs: number): void {
if (this.inFlight.has(key)) return;
if (nowMs < this.cooldownUntilMs) return;
if (this.queued.has(key)) return;
this.queued.set(key, {url: stripAttachmentSignature(url), resolvers: []});
this.scheduleFlush();
}
private request(key: string, url: string, nowMs: number): Promise<string | null> {
const existing = this.inFlight.get(key);
if (existing) return existing;
if (nowMs < this.cooldownUntilMs) return Promise.resolve(null);
const queued = this.queued.get(key) ?? {url: stripAttachmentSignature(url), resolvers: []};
this.queued.set(key, queued);
this.scheduleFlush();
return new Promise<string | null>((resolve) => {
queued.resolvers.push(resolve);
});
}
private scheduleFlush(): void {
if (this.flushTimer !== null) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
void this.flush();
}, FLUSH_WINDOW_MS);
}
private async flush(): Promise<void> {
if (this.queued.size === 0) return;
const nowMs = Date.now();
if (nowMs < this.cooldownUntilMs) {
this.drainQueue();
return;
}
const batch = Array.from(this.queued.entries()).slice(0, MAX_URLS_PER_REQUEST);
for (const [key] of batch) this.queued.delete(key);
if (this.queued.size > 0) this.scheduleFlush();
const generation = this.generation;
const send = this.send(batch, generation);
for (const [key] of batch) {
this.inFlight.set(
key,
send.then((outcome) => outcome.get(key) ?? null),
);
}
const outcome = await send;
for (const [key, request] of batch) {
if (generation === this.generation) this.inFlight.delete(key);
const fields = outcome.get(key) ?? null;
for (const resolve of request.resolvers) resolve(fields);
}
}
private async send(batch: Array<[string, QueuedRequest]>, generation: number): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
try {
const response = await http.post<RefreshAttachmentUrlsResponse>(Endpoints.ATTACHMENTS_REFRESH_URLS, {
body: {attachment_urls: batch.map(([, request]) => request.url)},
});
if (generation !== this.generation) return resolved;
const byOriginal = new Map(
(response.body?.refreshed_urls ?? []).map((entry) => [entry.original, entry.refreshed]),
);
const nowMs = Date.now();
const updates: Array<[string, CacheEntry]> = [];
for (const [key, request] of batch) {
const refreshed = byOriginal.get(request.url);
const fields = refreshed === undefined ? null : readAttachmentSignatureFields(refreshed);
const expiry = refreshed === undefined ? null : readAttachmentUrlSignature(refreshed);
if (fields === null || expiry === null) {
updates.push([key, {kind: 'blocked', untilMs: nowMs + UNSIGNED_RESULT_COOLDOWN_MS}]);
continue;
}
resolved.set(key, fields);
updates.push([
key,
{kind: 'signature', fields, goodUntilMs: expiry.expiresAtMs - ATTACHMENT_URL_REFRESH_MARGIN_MS},
]);
}
runInAction(() => {
this.forgetExpired(nowMs);
for (const [key, entry] of updates) this.entries.set(key, entry);
this.revision += 1;
});
return resolved;
} catch (error) {
if (generation !== this.generation) return resolved;
const nowMs = Date.now();
if (error instanceof HttpError && error.status === 429) {
this.cooldownUntilMs = nowMs + (resolveRetryAfterMs(error) ?? RATE_LIMIT_FALLBACK_COOLDOWN_MS);
return resolved;
}
logger.warn('Failed to refresh attachment URLs', error);
runInAction(() => {
this.forgetExpired(nowMs);
for (const [key] of batch) {
this.entries.set(key, {kind: 'blocked', untilMs: nowMs + TRANSPORT_ERROR_COOLDOWN_MS});
}
this.revision += 1;
});
return resolved;
}
}
private drainQueue(): void {
for (const request of this.queued.values()) {
for (const resolve of request.resolvers) resolve(null);
}
this.queued.clear();
}
}
export default new AttachmentUrlRefresher();
@@ -0,0 +1,198 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const ATTACHMENT_URL_REFRESH_MARGIN_MS = 3_600_000;
export interface EndpointInfo {
basePath: string;
origin: string;
}
export interface AttachmentUrlSignature {
expiresAtMs: number;
}
const ATTACHMENT_PATH_SEGMENT = '/attachments/';
const WINDOW_HEX_PATTERN = /^[0-9a-f]{8}$/u;
const MAC_HEX_PATTERN = /^[0-9a-f]{64}$/u;
const DIGITS_PATTERN = /^[0-9]+$/u;
const PLUS_PATTERN = /\+/gu;
const DATA_PACKAGE_USE_CASE = 'dp';
const DATA_PACKAGE_EXPIRY = '0';
export function parseEndpoint(endpoint: string): EndpointInfo | null {
if (!endpoint) return null;
try {
const parsedEndpoint = new URL(endpoint);
const basePath =
parsedEndpoint.pathname.length > 1 && parsedEndpoint.pathname.endsWith('/')
? parsedEndpoint.pathname.slice(0, -1)
: parsedEndpoint.pathname || '/';
return {
basePath,
origin: parsedEndpoint.origin,
};
} catch {
return null;
}
}
export function isUrlOnEndpoint(targetUrl: URL, endpoint: EndpointInfo): boolean {
if (targetUrl.origin !== endpoint.origin) return false;
if (endpoint.basePath === '/') return true;
return targetUrl.pathname === endpoint.basePath || targetUrl.pathname.startsWith(`${endpoint.basePath}/`);
}
export function isAttachmentCdnUrl(url: string, mediaEndpoint: string): boolean {
if (!url.includes(ATTACHMENT_PATH_SEGMENT)) return false;
const endpoint = parseEndpoint(mediaEndpoint);
if (endpoint === null) return false;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.username !== '' || parsed.password !== '') return false;
if (parsed.origin !== endpoint.origin) return false;
const prefix = endpoint.basePath === '/' ? ATTACHMENT_PATH_SEGMENT : `${endpoint.basePath}${ATTACHMENT_PATH_SEGMENT}`;
if (!parsed.pathname.startsWith(prefix)) return false;
const segments = parsed.pathname.slice(prefix.length).split('/');
if (segments.length < 3) return false;
const [channelId, attachmentId] = segments;
return (
DIGITS_PATTERN.test(channelId) &&
DIGITS_PATTERN.test(attachmentId) &&
segments.slice(2).every((segment) => segment.length > 0)
);
}
export function attachmentCacheKey(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return null;
}
}
interface SplitUrl {
base: string;
query: string;
fragment: string;
}
function splitUrl(url: string): SplitUrl {
const fragmentIndex = url.indexOf('#');
const head = fragmentIndex < 0 ? url : url.slice(0, fragmentIndex);
const fragment = fragmentIndex < 0 ? '' : url.slice(fragmentIndex);
const queryIndex = head.indexOf('?');
if (queryIndex < 0) return {base: head, query: '', fragment};
return {base: head.slice(0, queryIndex), query: head.slice(queryIndex + 1), fragment};
}
function formDecodeName(name: string): string {
try {
return decodeURIComponent(name.replace(PLUS_PATTERN, ' '));
} catch {
return name;
}
}
function fieldName(field: string): string {
const separator = field.indexOf('=');
return formDecodeName(separator < 0 ? field : field.slice(0, separator));
}
function fieldValue(field: string): string {
const separator = field.indexOf('=');
return separator < 0 ? '' : field.slice(separator + 1);
}
type SignatureFieldName = 'ex' | 'is' | 'hm' | 'uc';
function isSignatureFieldName(name: string): name is SignatureFieldName {
return name === 'ex' || name === 'is' || name === 'hm' || name === 'uc';
}
function preservedFields(query: string): Array<string> {
if (query.length === 0) return [];
return query.split('&').filter((field) => {
if (field.length === 0 || field === '=') return false;
return !isSignatureFieldName(fieldName(field));
});
}
interface ScannedSignature {
ex: Array<string>;
is: Array<string>;
hm: Array<string>;
uc: Array<string>;
}
function scanSignatureFields(url: string): ScannedSignature {
const found: ScannedSignature = {ex: [], is: [], hm: [], uc: []};
const {query} = splitUrl(url);
if (query.length === 0) return found;
for (const field of query.split('&')) {
if (field.length === 0) continue;
const name = fieldName(field);
if (!isSignatureFieldName(name)) continue;
found[name].push(fieldValue(field));
}
return found;
}
interface AttachmentSignatureFields {
ex: string;
is: string;
hm: string;
uc: string;
expiresAtMs: number;
}
function parseAttachmentSignature(url: string): AttachmentSignatureFields | null {
const found = scanSignatureFields(url);
if (found.ex.length !== 1 || found.is.length !== 1 || found.hm.length !== 1 || found.uc.length > 1) return null;
const ex = found.ex[0];
const is = found.is[0];
const hm = found.hm[0];
if (!WINDOW_HEX_PATTERN.test(is) || !MAC_HEX_PATTERN.test(hm)) return null;
if (found.uc.length === 1) {
if (found.uc[0] !== DATA_PACKAGE_USE_CASE || ex !== DATA_PACKAGE_EXPIRY) return null;
return {ex, is, hm, uc: DATA_PACKAGE_USE_CASE, expiresAtMs: Number.POSITIVE_INFINITY};
}
if (!WINDOW_HEX_PATTERN.test(ex)) return null;
return {ex, is, hm, uc: '', expiresAtMs: Number.parseInt(ex, 16) * 1000};
}
export function readAttachmentUrlSignature(url: string): AttachmentUrlSignature | null {
const signature = parseAttachmentSignature(url);
return signature === null ? null : {expiresAtMs: signature.expiresAtMs};
}
export function readAttachmentSignatureFields(url: string): string | null {
const signature = parseAttachmentSignature(url);
if (signature === null) return null;
const fields = `ex=${signature.ex}&is=${signature.is}&hm=${signature.hm}`;
return signature.uc === '' ? fields : `${fields}&uc=${signature.uc}`;
}
export function stripAttachmentSignature(url: string): string {
const {base, query, fragment} = splitUrl(url);
const preserved = preservedFields(query);
if (preserved.length === 0) return `${base}${fragment}`;
return `${base}?${preserved.join('&')}${fragment}`;
}
export function applyAttachmentSignature(url: string, signatureFields: string): string {
const {base, query, fragment} = splitUrl(url);
const preserved = preservedFields(query);
if (preserved.length === 0) return `${base}?${signatureFields}${fragment}`;
return `${base}?${signatureFields}&${preserved.join('&')}${fragment}`;
}
export function attachmentUrlNeedsRefresh(url: string, nowMs: number): boolean {
const signature = readAttachmentUrlSignature(url);
if (signature === null) return true;
return signature.expiresAtMs <= nowMs + ATTACHMENT_URL_REFRESH_MARGIN_MS;
}
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {isUrlOnEndpoint, parseEndpoint} from '@app/features/messaging/utils/AttachmentCdnUrl';
import {isMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
import {supportsShowSaveFilePicker} from '@app/features/platform/types/Browser';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -10,34 +12,6 @@ const logger = new Logger('FileDownloadUtils');
type MediaType = 'image' | 'gif' | 'video' | 'audio' | 'file';
interface EndpointInfo {
basePath: string;
origin: string;
}
function parseEndpoint(endpoint: string): EndpointInfo | null {
if (!endpoint) return null;
try {
const parsedEndpoint = new URL(endpoint);
const basePath =
parsedEndpoint.pathname.length > 1 && parsedEndpoint.pathname.endsWith('/')
? parsedEndpoint.pathname.slice(0, -1)
: parsedEndpoint.pathname || '/';
return {
basePath,
origin: parsedEndpoint.origin,
};
} catch {
return null;
}
}
function isUrlOnEndpoint(targetUrl: URL, endpoint: EndpointInfo): boolean {
if (targetUrl.origin !== endpoint.origin) return false;
if (endpoint.basePath === '/') return true;
return targetUrl.pathname === endpoint.basePath || targetUrl.pathname.startsWith(`${endpoint.basePath}/`);
}
function appendMediaProxyDownloadParam(src: string): string {
let parsedSrc: URL;
try {
@@ -221,23 +195,21 @@ function downloadViaAnchor(src: string, suggestedName: string, options?: {append
export async function downloadFile(src: string, type: MediaType, providedFilename?: string): Promise<void> {
if (!src) return;
const suggestedName = deriveSuggestedName(src, type, providedFilename);
const target = await AttachmentUrlRefresher.refresh(src);
if (isElectron()) {
try {
const outcome = await downloadWithNative({
url: src,
suggestedName: deriveSuggestedName(src, type, providedFilename),
});
const outcome = await downloadWithNative({url: target, suggestedName});
if (outcome !== 'unavailable') return;
} catch (error) {
logger.warn('Native download failed', error);
return;
}
}
const suggestedName = deriveSuggestedName(src, type, providedFilename);
if (await downloadViaFileSystemAccess(src, suggestedName, type)) return;
if (await downloadViaFetchBlob(src, suggestedName)) return;
if (downloadViaAnchor(src, suggestedName)) return;
await openExternalUrl(appendMediaProxyDownloadParam(src));
if (await downloadViaFileSystemAccess(target, suggestedName, type)) return;
if (await downloadViaFetchBlob(target, suggestedName)) return;
if (downloadViaAnchor(target, suggestedName)) return;
await openExternalUrl(appendMediaProxyDownloadParam(target));
}
export function createDownloadHandler(src: string, type: MediaType, providedFilename?: string) {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
import Permission from '@app/features/permissions/state/Permission';
import UserSettings from '@app/features/user/state/UserSettings';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
@@ -49,7 +50,7 @@ export function canonicalizeMediaUrl(url: string | null | undefined): string | n
if (youtubeId) {
return `youtube:${youtubeId}`;
}
return parsed.href.replace(/\/$/, '');
return stripAttachmentSignature(parsed.href).replace(/\/$/, '');
} catch {
return null;
}
@@ -18,6 +18,7 @@ import {
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {EditAltTextModal} from '@app/features/messaging/components/modals/EditAltTextModal';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils';
import {buildMediaProxyURL, stripMediaProxyParams} from '@app/features/messaging/utils/MediaProxyUtils';
import Permission from '@app/features/permissions/state/Permission';
@@ -429,17 +430,18 @@ export async function copyMediaToClipboard({
);
return;
}
const freshSrc = await AttachmentUrlRefresher.refresh(originalSrc);
if (type === 'file') {
await TextCopyCommands.copy(i18n, originalSrc, true);
await TextCopyCommands.copy(i18n, freshSrc, true);
ToastCommands.createToast({type: 'success', children: i18n._(LINK_COPIED_TO_CLIPBOARD_DESCRIPTOR)});
return;
}
const baseProxyURL = proxyURL ? stripMediaProxyParams(proxyURL) : null;
const baseProxyURL = proxyURL ? await AttachmentUrlRefresher.refresh(stripMediaProxyParams(proxyURL)) : null;
const clipboardFileMediaType = getClipboardFileMediaType(type);
if (clipboardFileMediaType) {
const electronApi = getElectronAPI();
if (!electronApi?.clipboardWriteFile) {
await TextCopyCommands.copy(i18n, originalSrc, true);
await TextCopyCommands.copy(i18n, freshSrc, true);
ToastCommands.createToast({type: 'success', children: i18n._(URL_COPIED_TO_CLIPBOARD_DESCRIPTOR)});
return;
}
@@ -451,7 +453,7 @@ export async function copyMediaToClipboard({
timeout: 0,
});
const result = await electronApi.clipboardWriteFile({
url: baseProxyURL || originalSrc,
url: baseProxyURL || freshSrc,
suggestedName: defaultName,
mediaType: clipboardFileMediaType,
});
@@ -470,7 +472,7 @@ export async function copyMediaToClipboard({
const urlsToTry: Array<string> = [];
if (baseProxyURL) urlsToTry.push(buildMediaProxyURL(baseProxyURL, {format: 'png'}));
if (baseProxyURL) urlsToTry.push(baseProxyURL);
urlsToTry.push(originalSrc);
urlsToTry.push(freshSrc);
let toastId: string | null = null;
try {
toastId = ToastCommands.createToast({
@@ -494,7 +496,7 @@ export async function copyMediaToClipboard({
} catch (error) {
logger.error('Failed to copy image to clipboard:', error);
if (toastId) ToastCommands.destroyToast(toastId);
await TextCopyCommands.copy(i18n, originalSrc, true);
await TextCopyCommands.copy(i18n, freshSrc, true);
ToastCommands.createToast({
type: 'success',
children: i18n._(URL_COPIED_TO_CLIPBOARD_DESCRIPTOR),
@@ -511,7 +513,7 @@ export async function copyMediaLinkToClipboard({i18n, originalSrc}: {i18n: I18n;
);
return;
}
await TextCopyCommands.copy(i18n, originalSrc, true);
await TextCopyCommands.copy(i18n, await AttachmentUrlRefresher.refresh(originalSrc), true);
ToastCommands.createToast({
type: 'success',
children: i18n._(LINK_COPIED_TO_CLIPBOARD_DESCRIPTOR),
@@ -196,20 +196,26 @@ const getSafeExternalUrl = (href: string | null): string | null => {
}
};
async function refreshAttachmentUrl(url: string): Promise<string> {
const {default: AttachmentUrlRefresher} = await import('@app/features/messaging/state/AttachmentUrlRefresher');
return AttachmentUrlRefresher.refresh(url);
}
export async function openExternalUrl(url: string, target: string = '_blank') {
const safeUrl = getSafeExternalUrl(url);
if (!safeUrl) return;
const refreshedUrl = await refreshAttachmentUrl(safeUrl);
const electronApi = getElectronAPI();
if (electronApi) {
try {
await electronApi.openExternal(safeUrl);
await electronApi.openExternal(refreshedUrl);
return;
} catch (error) {
logger.error(' Failed to open external URL via Electron', error);
return;
}
}
window.open(safeUrl, target, 'noopener,noreferrer');
window.open(refreshedUrl, target, 'noopener,noreferrer');
}
interface ExternalLinkClickEvent {
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PAUSE_DESCRIPTOR, PLAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import styles from '@app/features/voice/components/media_player/AudioPlayer.module.css';
@@ -77,6 +78,7 @@ export function AudioPlayer({
});
const {volume, isMuted, setVolume, toggleMute} = useMediaVolume({mediaRef});
const {escalateToMetadata, sourceAttribute, preloadAttribute} = useMetadataPreload(src, hasStarted);
const handleMediaError = useAttachmentRefreshOnError(src);
useEffect(() => {
if (hasStarted && pendingPlayRef.current) {
const timer = setTimeout(() => {
@@ -210,6 +212,7 @@ export function AudioPlayer({
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={sourceAttribute}
preload={preloadAttribute}
onError={handleMediaError}
data-flx="voice.media-player.audio-player.audio"
/>
{title && (
@@ -8,6 +8,7 @@ import {
PLAY_DESCRIPTOR,
REMOVE_FROM_FAVORITES_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
@@ -111,6 +112,7 @@ export function InlineAudioPlayer({
mediaRef,
});
const {escalateToMetadata, sourceAttribute, preloadAttribute} = useMetadataPreload(src, hasStarted);
const handleMediaError = useAttachmentRefreshOnError(src);
const displayDuration = duration > 0 ? duration : (initialDuration ?? 0);
useLayoutEffect(() => {
const media = mediaRef.current;
@@ -205,6 +207,7 @@ export function InlineAudioPlayer({
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={sourceAttribute}
preload={preloadAttribute}
onError={handleMediaError}
data-flx="voice.media-player.inline-audio-player.audio"
>
<track kind="captions" data-flx="voice.media-player.inline-audio-player.track" />
@@ -332,6 +335,7 @@ export function InlineAudioPlayer({
ref={mediaRef as React.RefObject<HTMLAudioElement>}
src={sourceAttribute}
preload={preloadAttribute}
onError={handleMediaError}
data-flx="voice.media-player.inline-audio-player.audio--2"
>
<track kind="captions" data-flx="voice.media-player.inline-audio-player.track--2" />