Add base redirect and corresponding unit test (#175 and #360)

This commit is contained in:
dangered wolf 2023-08-22 15:15:30 -04:00
parent 2d8f70f7a3
commit 3a852d1eb6
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
3 changed files with 264 additions and 71 deletions

View file

@ -16,6 +16,24 @@ declare const globalThis: {
const router = Router();
const getBaseRedirectUrl = (request: IRequest) => {
const baseRedirect = request.headers
?.get('cookie')
?.match(/(?<=base_redirect=)(.*?)(?=;|$)/)?.[0];
if (baseRedirect) {
console.log('Found base redirect', baseRedirect);
try {
new URL(baseRedirect);
} catch (e) {
return Constants.TWITTER_ROOT;
}
return baseRedirect.endsWith('/') ? baseRedirect.slice(0, -1) : baseRedirect;
}
return Constants.TWITTER_ROOT;
};
/* Handler for status (Tweet) request */
const statusRequest = async (
request: IRequest,
@ -94,6 +112,8 @@ const statusRequest = async (
console.log(`We're using twitter domain`);
}
const baseUrl = getBaseRedirectUrl(request);
/* Check if request is to api.fxtwitter.com, or the tweet is appended with .json
Note that unlike TwitFix, FixTweet will never generate embeds for .json, and
in fact we only support .json because it's what people using TwitFix API would
@ -136,13 +156,27 @@ const statusRequest = async (
Since we obviously have no media to give the user, we'll just redirect to the Tweet.
Embeds will return as usual to bots as if direct media was never specified. */
if (!isBotUA) {
return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}/status/${id}`, 302);
const baseUrl = getBaseRedirectUrl(request);
/* Do not cache if using a custom redirect */
const cacheControl = baseUrl !== Constants.TWITTER_ROOT ? 'max-age=0' : undefined;
return new Response(null, {
status: 302,
headers: {
'Location': `${baseUrl}/${handle}/status/${id}`,
...(cacheControl ? { 'cache-control': cacheControl } : {})
}
});
}
let headers = Constants.RESPONSE_HEADERS;
if (statusResponse.cacheControl) {
headers = { ...headers, 'cache-control': statusResponse.cacheControl };
headers = {
...headers,
'cache-control':
baseUrl !== Constants.TWITTER_ROOT ? 'max-age=0' : statusResponse.cacheControl
};
}
/* Return the response containing embed information */
@ -162,10 +196,17 @@ const statusRequest = async (
/* A human has clicked a fxtwitter.com/:screen_name/status/:id link!
Obviously we just need to redirect to the Tweet directly.*/
console.log('Matched human UA', userAgent);
return Response.redirect(
`${Constants.TWITTER_ROOT}/${handle}/status/${id?.match(/\d{2,20}/)?.[0]}`,
302
);
const cacheControl = baseUrl !== Constants.TWITTER_ROOT ? 'max-age=0' : undefined;
return new Response(null, {
status: 302,
headers: {
'Location': `${baseUrl}/${handle}/status/${id?.match(/\d{2,20}/)?.[0]}`,
...(cacheControl ? { 'cache-control': cacheControl } : {})
}
});
}
};
@ -221,14 +262,32 @@ const profileRequest = async (
} else if (profileResponse.text) {
console.log('handleProfile sent embed');
/* TODO This check has purpose in the original handleStatus handler, but I'm not sure if this edge case can happen here */
const baseUrl = getBaseRedirectUrl(request);
/* Check for custom redirect */
if (!isBotUA) {
return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}`, 302);
/* Do not cache if using a custom redirect */
const cacheControl = baseUrl !== Constants.TWITTER_ROOT ? 'max-age=0' : undefined;
return new Response(null, {
status: 302,
headers: {
'Location': `${baseUrl}/${handle}`,
...(cacheControl ? { 'cache-control': cacheControl } : {})
}
});
}
let headers = Constants.RESPONSE_HEADERS;
if (profileResponse.cacheControl) {
headers = { ...headers, 'cache-control': profileResponse.cacheControl };
headers = {
...headers,
'cache-control':
baseUrl !== Constants.TWITTER_ROOT
? 'max-age=0'
: profileResponse.cacheControl
};
}
/* Return the response containing embed information */
@ -239,7 +298,7 @@ const profileRequest = async (
} else {
/* Somehow handleStatus sent us nothing. This should *never* happen, but we have a case for it. */
return new Response(Strings.ERROR_UNKNOWN, {
headers: Constants.RESPONSE_HEADERS,
headers: { ...Constants.RESPONSE_HEADERS, 'cache-control': 'max-age=0' },
status: 500
});
}
@ -247,13 +306,34 @@ const profileRequest = async (
/* A human has clicked a fxtwitter.com/:screen_name link!
Obviously we just need to redirect to the user directly.*/
console.log('Matched human UA', userAgent);
return Response.redirect(`${Constants.TWITTER_ROOT}/${handle}`, 302);
const baseUrl = getBaseRedirectUrl(request);
/* Do not cache if using a custom redirect */
const cacheControl = baseUrl !== Constants.TWITTER_ROOT ? 'max-age=0' : undefined;
return new Response(null, {
status: 302,
headers: {
'Location': `${baseUrl}/${handle}`,
...(cacheControl ? { 'cache-control': cacheControl } : {})
}
});
}
};
const genericTwitterRedirect = async (request: IRequest) => {
const url = new URL(request.url);
return Response.redirect(`${Constants.TWITTER_ROOT}${url.pathname}`, 302);
const baseUrl = getBaseRedirectUrl(request);
/* Do not cache if using a custom redirect */
const cacheControl = baseUrl !== Constants.TWITTER_ROOT ? 'max-age=0' : undefined;
return new Response(null, {
status: 302,
headers: {
'Location': `${baseUrl}${url.pathname}`,
...(cacheControl ? { 'cache-control': cacheControl } : {})
}
});
};
const versionRequest = async (request: IRequest) => {
@ -292,6 +372,70 @@ const versionRequest = async (request: IRequest) => {
);
};
const setRedirectRequest = async (request: IRequest) => {
/* Query params */
const { searchParams } = new URL(request.url);
let url = searchParams.get('url');
if (!url) {
/* Remove redirect URL */
return new Response(
Strings.MESSAGE_HTML.format({
message: `Your base redirect has been cleared. To set one, please pass along the <code>url</code> parameter.`
}),
{
headers: {
'set-cookie': `base_redirect=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly`,
...Constants.RESPONSE_HEADERS
},
status: 200
}
);
}
try {
new URL(url);
} catch (e) {
try {
new URL(`https://${url}`);
} catch (e) {
/* URL is not well-formed, remove */
console.log('Invalid base redirect URL, removing cookie before redirect');
return new Response(
Strings.MESSAGE_HTML.format({
message: `Your URL does not appear to be well-formed. Example: ?url=https://nitter.net`
}),
{
headers: {
'set-cookie': `base_redirect=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; HttpOnly`,
...Constants.RESPONSE_HEADERS
},
status: 200
}
);
}
url = `https://${url}`;
}
/* Set cookie for url */
return new Response(
Strings.MESSAGE_HTML.format({
message: `Successfully set base redirect, you will now be redirected to ${sanitizeText(
url
)} rather than ${Constants.TWITTER_ROOT}`
}),
{
headers: {
'set-cookie': `base_redirect=${url}; path=/; max-age=63072000; secure; HttpOnly`,
...Constants.RESPONSE_HEADERS
},
status: 200
}
);
};
/* TODO: is there any way to consolidate these stupid routes for itty-router?
I couldn't find documentation allowing for regex matching */
router.get('/:prefix?/:handle/status/:id', statusRequest);
@ -307,6 +451,7 @@ router.get('/:prefix?/:handle/statuses/:id/:language', statusRequest);
router.get('/status/:id', statusRequest);
router.get('/status/:id/:language', statusRequest);
router.get('/version', versionRequest);
router.get('/set_base_redirect', setRedirectRequest);
/* Oembeds (used by Discord to enhance responses)
@ -424,7 +569,10 @@ export const cacheWrapper = async (
switch (request.method) {
case 'GET':
if (!Constants.API_HOST_LIST.includes(cacheUrl.hostname)) {
if (
!Constants.API_HOST_LIST.includes(cacheUrl.hostname) &&
!request.headers?.get('Cookie')?.includes('base_redirect')
) {
/* cache may be undefined in tests */
const cachedResponse = await cache.match(cacheKey);

View file

@ -66,64 +66,98 @@ This is caused by Twitter API downtime or a new bug. Try again in a little while
.replace(/( {2})/g, '')
.replace(/>\s+</gm, '><'),
VERSION_HTML: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="${BRANDING_NAME}" property="og:title"/>
<meta content="${BRANDING_NAME}" property="og:site_name"/>
<meta content="https://cdn.discordapp.com/icons/958942151817977906/7a220767640cbedbf780767585eaa10d.png?size=96" property="og:image"/>
<meta content="https://cdn.discordapp.com/icons/958942151817977906/7a220767640cbedbf780767585eaa10d.png?size=96" property="twitter:image"/>
<meta content="#1E98F0" name="theme-color"/>
<meta content="Worker release: ${RELEASE_NAME}
Stats for nerds:
🕵 {ua}
🌐 {ip}
🌎 {city}, {region}, {country}
🛴 {asn}
Edge Connection:
{rtt} 📶 {httpversion} 🔒 {tlsversion} {colo}
" property="og:description"/></head>
<title>${BRANDING_NAME}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 0 20px;
}
h1 {
font-size: 4em;
font-weight: 900;
margin-bottom: 0;
}
h2 {
white-space: pre-wrap;
}
p {
font-size: 10px;
opacity: 0.3;
}
.cf {
display: inline-block;
vertical-align: middle;
height: 48px;
width: 48px;
}
</style>
</head>
<body>
<h1>${BRANDING_NAME}</h1>
<h3>A better way to embed X / Twitter posts on Discord, Telegram, and more.</h2>
<h2>Worker release: ${RELEASE_NAME}</h2>
<br>
<h3>Stats for nerds:</h3>
<h2>Edge Connection:
{rtt} 📶 {httpversion} 🔒 {tlsversion} <img class="cf" referrerpolicy="no-referrer" src="https://cdn.discordapp.com/emojis/988895299693080616.webp?size=96&quality=lossless"> {colo}</h2>
<h2>User Agent:
{ua}</h2>
</body>
</html>`
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="${BRANDING_NAME}" property="og:title"/>
<meta content="${BRANDING_NAME}" property="og:site_name"/>
<meta content="https://cdn.discordapp.com/icons/958942151817977906/7a220767640cbedbf780767585eaa10d.png?size=96" property="og:image"/>
<meta content="https://cdn.discordapp.com/icons/958942151817977906/7a220767640cbedbf780767585eaa10d.png?size=96" property="twitter:image"/>
<meta content="#1E98F0" name="theme-color"/>
<meta content="Worker release: ${RELEASE_NAME}
Stats for nerds:
🕵 {ua}
🌐 {ip}
🌎 {city}, {region}, {country}
🛴 {asn}
Edge Connection:
{rtt} 📶 {httpversion} 🔒 {tlsversion} {colo}
" property="og:description"/></head>
<title>${BRANDING_NAME}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 0 20px;
}
h1 {
font-size: 4em;
font-weight: 900;
margin-bottom: 0;
}
h2 {
white-space: pre-wrap;
}
p {
font-size: 10px;
opacity: 0.3;
}
.cf {
display: inline-block;
vertical-align: middle;
height: 48px;
width: 48px;
}
</style>
</head>
<body>
<h1>${BRANDING_NAME}</h1>
<h3>A better way to embed X / Twitter posts on Discord, Telegram, and more.</h2>
<h2>Worker release: ${RELEASE_NAME}</h2>
<br>
<h3>Stats for nerds:</h3>
<h2>Edge Connection:
{rtt} 📶 {httpversion} 🔒 {tlsversion} <img class="cf" referrerpolicy="no-referrer" src="https://cdn.discordapp.com/emojis/988895299693080616.webp?size=96&quality=lossless"> {colo}</h2>
<h2>User Agent:
{ua}</h2>
</body>
</html>`
.replace(/( {2})/g, '')
.replace(/>\s+</gm, '><'),
MESSAGE_HTML: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="${BRANDING_NAME}" property="og:title"/>
<meta content="${BRANDING_NAME}" property="og:site_name"/>
<title>${BRANDING_NAME}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 0 20px;
}
h1 {
font-size: 4em;
font-weight: 900;
margin-bottom: 0;
}
h2 {
white-space: pre-wrap;
}
p {
font-size: 10px;
opacity: 0.3;
}
</style>
</head>
<body>
<h1>${BRANDING_NAME}</h1>
<h2>{message}</h2>
</body>
</html>`
.replace(/( {2})/g, '')
.replace(/>\s+</gm, '><'),
DEFAULT_AUTHOR_TEXT: 'Twitter',

View file

@ -3,7 +3,7 @@ import { cacheWrapper } from '../src/server';
const botHeaders = { 'User-Agent': 'Discordbot/2.0' };
const humanHeaders = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36'
};
const githubUrl = 'https://github.com/FixTweet/FixTweet';
const twitterBaseUrl = 'https://twitter.com';
@ -54,6 +54,17 @@ test('Tweet redirect human', async () => {
expect(result.headers.get('location')).toEqual('https://twitter.com/jack/status/20');
});
test('Tweet redirect human custom base redirect', async () => {
const result = await cacheWrapper(
new Request('https://fxtwitter.com/jack/status/20', {
method: 'GET',
headers: { ...humanHeaders, 'Cookie': 'cf_clearance=a; base_redirect=https://nitter.net' }
})
);
expect(result.status).toEqual(302);
expect(result.headers.get('location')).toEqual('https://nitter.net/jack/status/20');
});
test('Twitter moment redirect', async () => {
const result = await cacheWrapper(
new Request(