diff --git a/fluxer_api/pkgs/media_proxy_utils/package.json b/fluxer_api/pkgs/media_proxy_utils/package.json index d0be520e7..d97db0c8e 100644 --- a/fluxer_api/pkgs/media_proxy_utils/package.json +++ b/fluxer_api/pkgs/media_proxy_utils/package.json @@ -29,12 +29,16 @@ "main": "./src/MediaProxyUtils.ts", "types": "./src/MediaProxyUtils.ts", "scripts": { + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsgo --noEmit" }, "dependencies": { "@types/node": "catalog:" }, "devDependencies": { - "@typescript/native-preview": "catalog:" + "@typescript/native-preview": "catalog:", + "vitest": "catalog:", + "vite-tsconfig-paths": "catalog:" } } diff --git a/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts b/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts new file mode 100644 index 000000000..2bc7260cf --- /dev/null +++ b/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + buildExternalMediaProxyPath, + buildV2ExternalMediaProxyPath, + reconstructOriginalUrl, +} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec'; +import {describe, expect, it} from 'vitest'; + +const ROUND_TRIP_URLS = [ + 'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp', + 'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here', + 'https://example.com:8443/a.png', + 'https://avatars.githubusercontent.com/u/241303489?v=4', + 'http://example.com/plain.gif', + 'https://example.com/deep/nested/path/to/file.jpeg', + 'https://example.com/file.png?a=1&b=2&c=3', + 'https://example.com/spaced%20name.png', + 'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png', + 'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png', + 'https://sub.domain.example.co.uk/a/b.webp', +]; + +describe('buildExternalMediaProxyPath', () => { + it('emits the plain path shape with the extension last', () => { + expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe( + 'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp', + ); + }); + + it('puts an encoded query, leading question mark included, ahead of the protocol', () => { + expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe( + '%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp', + ); + }); + + it('keeps a non default port on the host segment', () => { + expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png'); + }); + + it('preserves the http scheme', () => { + expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif'); + }); + + it('handles a root url with no path', () => { + expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com'); + }); + + it('never emits the v2 prefix any more', () => { + for (const url of ROUND_TRIP_URLS) { + expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false); + } + }); + + it('ends in the source file extension so extension based cdn caching applies', () => { + for (const [url, ext] of [ + ['https://example.com/a.webp', '.webp'], + ['https://example.com/a.png?x=1', '.png'], + ['https://example.com/a/b/c.jpeg', '.jpeg'], + ] as const) { + expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true); + } + }); + + it('rejects a url it cannot parse', () => { + expect(() => buildExternalMediaProxyPath('not a url')).toThrow(); + }); +}); + +describe('reconstructOriginalUrl', () => { + it('round trips every supported shape', () => { + for (const url of ROUND_TRIP_URLS) { + expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url); + } + }); + + it('decodes an externally produced path verbatim', () => { + expect( + reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'), + ).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here'); + }); + + it('does not double the question mark when the query segment carries one', () => { + const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png'); + expect(decoded).toBe('https://example.com/x.png?a=1'); + expect(decoded).not.toContain('??'); + }); + + it('still accepts a query segment without a leading question mark', () => { + expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1'); + }); + + it('still decodes v2 paths so links already sent keep working', () => { + expect(reconstructOriginalUrl(buildV2ExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe( + 'https://example.com/a.png?x=1', + ); + }); + + it('rejects a path that has no host after the protocol', () => { + expect(() => reconstructOriginalUrl('https')).toThrow(); + }); + + it('rejects an empty v2 payload', () => { + expect(() => reconstructOriginalUrl('v2/')).toThrow(); + }); +}); diff --git a/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.ts b/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.ts index d5d1b5883..27d57dd99 100644 --- a/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.ts +++ b/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.ts @@ -74,7 +74,8 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string { const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : ''; const path = decodeLegacyComponent(encodedPath); const {hostname, port} = decodeLegacyHostAndPort(hostPart); - return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`; + const normalizedQuery = query.startsWith('?') ? query.slice(1) : query; + return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`; } function reconstructV2OriginalUrl(proxyUrlPath: string): string { @@ -85,11 +86,28 @@ function reconstructV2OriginalUrl(proxyUrlPath: string): string { return decodeV2PathComponent(encodedOriginalUrl); } -export function buildExternalMediaProxyPath(inputUrl: string): string { +export function buildV2ExternalMediaProxyPath(inputUrl: string): string { const parsedUrl = new URL(inputUrl); return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`; } +export function buildExternalMediaProxyPath(inputUrl: string): string { + const parsedUrl = new URL(inputUrl); + const protocol = parsedUrl.protocol.replace(/:$/u, ''); + const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname; + const path = parsedUrl.pathname + .replace(/^\//u, '') + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : []; + segments.push(protocol, host); + if (path) { + segments.push(path); + } + return segments.join('/'); +} + export function reconstructOriginalUrl(proxyUrlPath: string): string { const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX) ? reconstructV2OriginalUrl(proxyUrlPath) diff --git a/fluxer_api/pkgs/media_proxy_utils/tsconfig.json b/fluxer_api/pkgs/media_proxy_utils/tsconfig.json new file mode 100644 index 000000000..97f2c7f13 --- /dev/null +++ b/fluxer_api/pkgs/media_proxy_utils/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfigs/package.json", + "compilerOptions": { + "paths": { + "@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"], + "@pkgs/*": ["../*"] + } + }, + "include": ["src/**/*"] +} diff --git a/fluxer_api/pkgs/media_proxy_utils/vitest.config.ts b/fluxer_api/pkgs/media_proxy_utils/vitest.config.ts new file mode 100644 index 000000000..08796abc6 --- /dev/null +++ b/fluxer_api/pkgs/media_proxy_utils/vitest.config.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import {defineConfig} from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + root: path.resolve(__dirname, '../..'), + }), + ], + test: { + globals: true, + environment: 'node', + include: ['**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'dist'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'], + }, + }, +}); diff --git a/fluxer_media_proxy/src/external_path.rs b/fluxer_media_proxy/src/external_path.rs index 02bdc716a..f211aa4d8 100644 --- a/fluxer_media_proxy/src/external_path.rs +++ b/fluxer_media_proxy/src/external_path.rs @@ -15,13 +15,70 @@ pub enum ExternalPathError { InvalidUtf8, } -pub fn build_external_media_proxy_path(input_url: &str) -> String { +pub fn build_v2_external_media_proxy_path(input_url: &str) -> String { format!( "{V2_PREFIX}{}", BASE64_URL_SAFE_NO_PAD.encode(input_url.as_bytes()) ) } +fn percent_encode_component(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'!' + | b'~' + | b'*' + | b'\'' + | b'(' + | b')' => out.push(byte as char), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +pub fn build_external_media_proxy_path(input_url: &str) -> Result { + let (scheme, remainder) = input_url + .split_once("://") + .ok_or(ExternalPathError::InvalidExternalPath)?; + if scheme.is_empty() || remainder.is_empty() { + return Err(ExternalPathError::InvalidExternalPath); + } + let (authority_and_path, query) = match remainder.split_once('?') { + Some((head, tail)) => (head, Some(tail)), + None => (remainder, None), + }; + let (host, path) = match authority_and_path.split_once('/') { + Some((host, path)) => (host, path), + None => (authority_and_path, ""), + }; + if host.is_empty() { + return Err(ExternalPathError::InvalidExternalPath); + } + let mut segments: Vec = Vec::new(); + if let Some(query) = query { + segments.push(percent_encode_component(&format!("?{query}"))); + } + segments.push(scheme.to_owned()); + segments.push(host.to_owned()); + if !path.is_empty() { + segments.push( + path.split('/') + .map(percent_encode_component) + .collect::>() + .join("/"), + ); + } + Ok(segments.join("/")) +} + fn decode_v2(proxy_path: &str) -> Result { let encoded = proxy_path .strip_prefix(V2_PREFIX) @@ -105,10 +162,10 @@ fn reconstruct_legacy(proxy_path: &str) -> Result { let path_raw = parts[protocol_index + 2..].join("/"); let query = percent_decode_string(&query_raw, false); let path = percent_decode_string(&path_raw, false); + let normalized_query = query.strip_prefix('?').unwrap_or(&query); Ok(format!( - "{protocol}://{host_port}/{path}{}{}", - if query.is_empty() { "" } else { "?" }, - query + "{protocol}://{host_port}/{path}{}{normalized_query}", + if normalized_query.is_empty() { "" } else { "?" } )) } @@ -124,9 +181,76 @@ pub fn reconstruct_original_url(proxy_path: &str) -> Result