diff --git a/package-lock.json b/package-lock.json index 48508a28..9672ae8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.10.2", + "version": "4.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.10.2", + "version": "4.10.3", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", @@ -58,7 +58,7 @@ "react-google-recaptcha": "2.1.0", "react-i18next": "15.0.0", "react-range": "1.8.14", - "react-router-dom": "6.20.0", + "react-router-dom": "6.30.3", "sanitize-html": "2.12.1", "slate": "0.112.0", "slate-dom": "0.112.2", @@ -3721,9 +3721,10 @@ } }, "node_modules/@remix-run/router": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.13.0.tgz", - "integrity": "sha512-5dMOnVnefRsl4uRnAdoWjtVTdh8e6aZqgM4puy9nmEADH72ck+uXwzpJLEKE9Q6F8ZljNewLgmTfkxUrBdv4WA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -9627,11 +9628,12 @@ } }, "node_modules/react-router": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.20.0.tgz", - "integrity": "sha512-pVvzsSsgUxxtuNfTHC4IxjATs10UaAtvLGVSA1tbUE4GDaOSU1Esu2xF5nWLz7KPiMuW8BJWuPFdlGYJ7/rW0w==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.13.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -9641,12 +9643,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.20.0.tgz", - "integrity": "sha512-CbcKjEyiSVpA6UtCHOIYLUYn/UJfwzp55va4yEfpk7JBN3GPqWfHrdLkAvNCcpXr8QoihcDMuk0dzWZxtlB/mQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.13.0", - "react-router": "6.20.0" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" diff --git a/package.json b/package.json index bcf30c93..449ad691 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.2", + "version": "4.10.3", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -70,7 +70,7 @@ "react-google-recaptcha": "2.1.0", "react-i18next": "15.0.0", "react-range": "1.8.14", - "react-router-dom": "6.20.0", + "react-router-dom": "6.30.3", "sanitize-html": "2.12.1", "slate": "0.112.0", "slate-dom": "0.112.2", diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index fa3d7592..3b13e487 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -51,8 +51,12 @@ export function BackRouteHandler({ children }: BackRouteHandlerProps) { }, location.pathname ); - if (spaceMatch?.params.spaceIdOrAlias) { - navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias)); + const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias; + const decodedSpaceIdOrAlias = + encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); + + if (decodedSpaceIdOrAlias) { + navigate(getSpacePath(decodedSpaceIdOrAlias)); return; } if ( diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index cc0dff19..d358ff7d 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -88,6 +88,8 @@ export function EmoticonAutocomplete({ {autoCompleteEmoticon.map((emoticon) => { const isCustomEmoji = 'url' in emoticon; const key = isCustomEmoji ? emoticon.url : emoticon.unicode; + const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication); + return ( handleAutocomplete(key, emoticon.shortcode)} before={ - isCustomEmoji ? ( + isCustomEmoji && customEmojiUrl ? ( diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 3db27e2a..d5a76c71 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -202,8 +202,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; const url = - mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || - pack.meta.avatar; + mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; return ( ); @@ -98,7 +98,7 @@ export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) loading="lazy" className={css.StickerImg} alt={image.body || image.shortcode} - src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? image.url} + src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''} /> ); diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 0248862d..2ffc9ec4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -27,7 +27,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow const [downloadState, download] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 71551b12..478486a4 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -54,7 +54,8 @@ export function AudioContent({ const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index ad54c531..7e127f2a 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -86,7 +86,8 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea const [textState, loadText] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); @@ -176,7 +177,8 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read const [pdfState, loadPdf] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); @@ -253,7 +255,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil const [downloadState, download] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo)) : await downloadMedia(mediaUrl); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 84e3709e..4f1d9c75 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -87,7 +87,8 @@ export const ImageContent = as<'div', ImageContentProps>( const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) diff --git a/src/app/components/message/content/ThumbnailContent.tsx b/src/app/components/message/content/ThumbnailContent.tsx index 0746137c..aa9171aa 100644 --- a/src/app/components/message/content/ThumbnailContent.tsx +++ b/src/app/components/message/content/ThumbnailContent.tsx @@ -23,7 +23,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) { throw new Error('Failed to load thumbnail'); } - const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl; + const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); if (encInfo) { const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo) diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 52073ac1..b33ec272 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -81,7 +81,8 @@ export const VideoContent = as<'div', VideoContentProps>( const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url; + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); const fileContent = encInfo ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo) diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index f2cabf1d..7cf121d5 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.10.2 + v4.10.3 Yet another matrix client. diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index d5e8ee22..2e791da2 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -70,6 +70,7 @@ import { SearchModalRenderer } from '../features/search'; import { getFallbackSession } from '../state/sessions'; import { CallProvider } from './client/call/CallProvider'; import { PersistentCallContainer } from './client/call/PersistentCallContainer'; +import { pushSessionToSW } from '../../sw-session'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -108,7 +109,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) { - if (!getFallbackSession()) { + const session = getFallbackSession(); + if (!session) { const afterLoginPath = getAppPathFromHref( getOriginBaseUrl(hashRouter), window.location.href @@ -116,6 +118,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath); return redirect(getLoginPath()); } + pushSessionToSW(session.baseUrl, session.accessToken); return null; }} element={ diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index d53ef9a6..124b0384 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.10.2 + v4.10.3 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 6eb33a16..969a5cd4 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.10.2 + v4.10.3 } diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 985c47b1..28e0db59 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -100,7 +100,7 @@ const transformATag: Transformer = (tagName, attribs) => ({ tagName, attribs: { ...attribs, - rel: 'noopener', + rel: 'noreferrer noopener', target: '_blank', }, }); @@ -112,7 +112,7 @@ const transformImgTag: Transformer = (tagName, attribs) => { tagName: 'a', attribs: { href: src, - rel: 'noopener', + rel: 'noreferrer noopener', target: '_blank', }, text: attribs.alt || src, diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 487c3f13..498d4f75 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; +import { pushSessionToSW } from '../sw-session'; type Session = { baseUrl: string; @@ -53,6 +54,7 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { }; export const logoutClient = async (mx: MatrixClient) => { + pushSessionToSW(); mx.stopClient(); try { await mx.logout(); diff --git a/src/index.tsx b/src/index.tsx index ddfd30b4..71e723ab 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,8 @@ import App from './app/pages/App'; // import i18n (needs to be bundled ;)) import './app/i18n'; +import { pushSessionToSW } from './sw-session'; +import { getFallbackSession } from './app/state/sessions'; document.body.classList.add(configClass, varsClass); @@ -25,17 +27,24 @@ if ('serviceWorker' in navigator) { ? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js` : `/dev-sw.js?dev-sw`; - navigator.serviceWorker.register(swUrl); - navigator.serviceWorker.addEventListener('message', (event) => { - if (event.data?.type === 'token' && event.data?.responseKey) { - // Get the token for SW. - const token = localStorage.getItem('cinny_access_token') ?? undefined; - event.source!.postMessage({ - responseKey: event.data.responseKey, - token, - }); + const sendSessionToSW = () => { + const session = getFallbackSession(); + pushSessionToSW(session?.baseUrl, session?.accessToken); + }; + + navigator.serviceWorker.register(swUrl).then(sendSessionToSW); + navigator.serviceWorker.ready.then(sendSessionToSW); + window.addEventListener('load', sendSessionToSW); + + // When returning from background + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + sendSessionToSW(); } }); + + // When restored from bfcache (important on iOS) + window.addEventListener('pageshow', sendSessionToSW); } const mountApp = () => { diff --git a/src/sw-session.ts b/src/sw-session.ts new file mode 100644 index 00000000..4b2ec055 --- /dev/null +++ b/src/sw-session.ts @@ -0,0 +1,10 @@ +export function pushSessionToSW(baseUrl?: string, accessToken?: string) { + if (!('serviceWorker' in navigator)) return; + if (!navigator.serviceWorker.controller) return; + + navigator.serviceWorker.controller.postMessage({ + type: 'setSession', + accessToken, + baseUrl, + }); +} diff --git a/src/sw.ts b/src/sw.ts index 2179dfcb..d4eebc02 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -3,22 +3,64 @@ export type {}; declare const self: ServiceWorkerGlobalScope; -async function askForAccessToken(client: Client): Promise { - return new Promise((resolve) => { - const responseKey = Math.random().toString(36); - const listener = (event: ExtendableMessageEvent) => { - if (event.data.responseKey !== responseKey) return; - resolve(event.data.token); - self.removeEventListener('message', listener); - }; - self.addEventListener('message', listener); - client.postMessage({ responseKey, type: 'token' }); +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event: ExtendableEvent) => { + event.waitUntil(self.clients.claim()); +}); + +type SessionInfo = { + accessToken: string; + baseUrl: string; +}; + +/** + * Store session per client (tab) + */ +const sessions = new Map(); + +async function cleanupDeadClients() { + const activeClients = await self.clients.matchAll(); + const activeIds = new Set(activeClients.map((c) => c.id)); + + Array.from(sessions.keys()).forEach((id) => { + if (!activeIds.has(id)) { + sessions.delete(id); + } }); } -function fetchConfig(token?: string): RequestInit | undefined { - if (!token) return undefined; +/** + * Receive session updates from clients + */ +self.addEventListener('message', (event: ExtendableMessageEvent) => { + const client = event.source as Client | null; + if (!client) return; + const { type, accessToken, baseUrl } = event.data || {}; + + if (type !== 'setSession') return; + + cleanupDeadClients(); + + if (typeof accessToken === 'string' && typeof baseUrl === 'string') { + sessions.set(client.id, { accessToken, baseUrl }); + } else { + // Logout or invalid session + sessions.delete(client.id); + } +}); + +function validMediaRequest(url: string, baseUrl: string): boolean { + const downloadUrl = new URL('/_matrix/client/v1/media/download', baseUrl); + const thumbnailUrl = new URL('/_matrix/client/v1/media/thumbnail', baseUrl); + + return url.startsWith(downloadUrl.href) || url.startsWith(thumbnailUrl.href); +} + +function fetchConfig(token: string): RequestInit { return { headers: { Authorization: `Bearer ${token}`, @@ -27,26 +69,16 @@ function fetchConfig(token?: string): RequestInit | undefined { }; } -self.addEventListener('activate', (event: ExtendableEvent) => { - event.waitUntil(clients.claim()); -}); - self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; - if (method !== 'GET') return; - if ( - !url.includes('/_matrix/client/v1/media/download') && - !url.includes('/_matrix/client/v1/media/thumbnail') - ) { - return; - } - event.respondWith( - (async (): Promise => { - const client = await self.clients.get(event.clientId); - let token: string | undefined; - if (client) token = await askForAccessToken(client); - return fetch(url, fetchConfig(token)); - })() - ); + if (method !== 'GET') return; + if (!event.clientId) return; + + const session = sessions.get(event.clientId); + if (!session) return; + + if (!validMediaRequest(url, session.baseUrl)) return; + + event.respondWith(fetch(url, fetchConfig(session.accessToken))); });