Skip to content

Instantly share code, notes, and snippets.

@hiing
Last active November 17, 2021 07:14
Show Gist options
  • Save hiing/dbd4223ddcd89fba8d4a975557448807 to your computer and use it in GitHub Desktop.
Save hiing/dbd4223ddcd89fba8d4a975557448807 to your computer and use it in GitHub Desktop.
Google Fonts 反代。在 fonts.googleapis.com 前加上 Worker域名,形如 https://fonts.XXX.workers.dev/fonts.googleapis.com/css?XXXXX
addEventListener("fetch", event => {
// Fail-safe in case of an unhandled exception
event.passThroughOnException();
if (event.request.method === 'GET') {
const url = new URL(event.request.url);
const accept = event.request.headers.get('Accept');
if (url.pathname.startsWith('/fonts.gstatic.com/')) {
// Pass the font requests through to the origin font server
// (through the underlying request cache).
event.respondWith(proxyRequest('https:/' + url.pathname + url.search,
event.request));
} else if (accept && (accept.indexOf('text/html') >= 0 || accept.indexOf('text/css') >= 0)) {
// The only interesting (non-proxied) requests are for HTML and CSS.
// All of the major browsers advertise they are requesting HTML or CSS in the accept header.
// For any browsers that don't (curl, etc), they will just fall-back to non-accelerated.
if (url.pathname.startsWith('/fonts.googleapis.com/')) {
// Proxy the stylesheet for pages using CSP
event.respondWith(proxyStylesheet('https:/' + url.pathname + url.search,
event.request));
} else {
event.respondWith(processRequest(event.request, event));
}
}
}
});
// Workers can only decode utf-8 so keep a list of character encodings that can be decoded.
const VALID_CHARSETS = ['utf-8', 'utf8', 'iso-8859-1', 'us-ascii'];
async function proxyRequest(url, request) {
let init = {
method: request.method,
headers: {}
};
// Only pass through a subset of headers
const proxyHeaders = ["Accept",
"Accept-Encoding",
"Accept-Language",
"Referer",
"User-Agent"];
for (let name of proxyHeaders) {
let value = request.headers.get(name);
if (value) {
init.headers[name] = value;
}
}
// Add an X-Forwarded-For with the client IP
const clientAddr = request.headers.get('cf-connecting-ip');
if (clientAddr) {
init.headers['X-Forwarded-For'] = clientAddr;
}
const response = await fetch(url, init);
if (response) {
const responseHeaders = ["Content-Type",
"Cache-Control",
"Expires",
"Accept-Ranges",
"Date",
"Last-Modified",
"ETag"];
// Only include a strict subset of response headers
let responseInit = {status: response.status,
statusText: response.statusText,
headers: {}};
for (let name of responseHeaders) {
let value = response.headers.get(name);
if (value) {
responseInit.headers[name] = value;
}
}
// Add some security headers to make sure there isn't scriptable content
// being proxied.
responseInit.headers['X-Content-Type-Options'] = "nosniff";
const newResponse = new Response(response.body, responseInit);
return newResponse;
}
return response;
}
async function proxyStylesheet(url, request) {
let css = await fetchCSS(url, request)
if (css) {
const responseInit = {headers: {
"Content-Type": "text/css; charset=utf-8",
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800"
}};
const newResponse = new Response(css, responseInit);
return newResponse;
} else {
// Do a straight-through proxy as fallback
return proxyRequest(url, request);
}
}
async function processRequest(request, event) {
const response = await fetch(request);
if (response && response.status === 200) {
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("text/html") !== -1) {
return await processHtmlResponse(response, event.request, event);
} else if (contentType && contentType.indexOf("text/css") !== -1) {
return await processStylesheetResponse(response, event.request, event);
}
}
return response;
}
async function processHtmlResponse(response, request, event) {
const contentType = response.headers.get("content-type");
const charsetRegex = /charset\s*=\s*([^\s;]+)/mgi;
const match = charsetRegex.exec(contentType);
if (match !== null) {
let charset = match[1].toLowerCase();
if (!VALID_CHARSETS.includes(charset)) {
return response;
}
}
let embedStylesheet = true;
let csp = response.headers.get("Content-Security-Policy");
if (csp) {
// Get the style policy that will be applied to the document
let ok = false;
let cspRule = null;
const styleRegex = /style-src[^;]*/gmi;
let match = styleRegex.exec(csp);
if (match !== null) {
cspRule = match[0];
} else {
const defaultRegex = /default-src[^;]*/gmi;
let match = defaultRegex.exec(csp);
if (match !== null) {
cspRule = match[0];
}
}
if (cspRule !== null) {
if (cspRule.indexOf("'unsafe-inline'") >= 0) {
ok = true;
embedStylesheet = true;
} else if (cspRule.indexOf("'self'") >= 0) {
ok = true;
embedStylesheet = false;
}
}
// If CSP is enabled but there are no style rules, just bail
// (shouldn't work even normally but no reason to touch it).
if (!ok) {
return response;
}
}
// Create an identity TransformStream (a.k.a. a pipe).
// The readable side will become our new response body.
const { readable, writable } = new TransformStream();
// Create a cloned response with our modified stream
const newResponse = new Response(readable, response);
// Start the async processing of the response stream
modifyHtmlStream(response.body, writable, request, event, embedStylesheet);
// Return the in-process response so it can be streamed.
return newResponse;
}
async function processStylesheetResponse(response, request, event) {
let body = response.body;
try {
body = await response.text();
const fontCSSRegex = /@import\s*(url\s*)?[\('"\s]+((https?:)?\/\/fonts.googleapis.com\/css[^'"\)]+)[\s'"\)]+\s*;/mgi;
let match = fontCSSRegex.exec(body);
while (match !== null) {
const matchString = match[0];
const fontCSS = await fetchCSS(match[2], request, event);
if (fontCSS.length) {
body = body.split(matchString).join(fontCSS);
fontCSSRegex.lastIndex -= matchString.length - fontCSS.length;
}
match = fontCSSRegex.exec(body);
}
} catch (e) {
// Ignore the exception, the original body will be passed through.
}
// Return a cloned response with the (possibly modified) body.
// We can't just return the original response since we already
// consumed the body.
const newResponse = new Response(body, response);
return newResponse;
}
function chunkContainsInvalidCharset(chunk) {
let invalid = false;
// meta charset
const charsetRegex = /<\s*meta[^>]+charset\s*=\s*['"]([^'"]*)['"][^>]*>/mgi;
const charsetMatch = charsetRegex.exec(chunk);
if (charsetMatch) {
const docCharset = charsetMatch[1].toLowerCase();
if (!VALID_CHARSETS.includes(docCharset)) {
invalid = true;
}
}
// content-type
const contentTypeRegex = /<\s*meta[^>]+http-equiv\s*=\s*['"]\s*content-type[^>]*>/mgi;
const contentTypeMatch = contentTypeRegex.exec(chunk);
if (contentTypeMatch) {
const metaTag = contentTypeMatch[0];
const metaRegex = /charset\s*=\s*([^\s"]*)/mgi;
const metaMatch = metaRegex.exec(metaTag);
if (metaMatch) {
const charset = metaMatch[1].toLowerCase();
if (!VALID_CHARSETS.includes(charset)) {
invalid = true;
}
}
}
return invalid;
}
async function modifyHtmlStream(readable, writable, request, event, embedStylesheet) {
const reader = readable.getReader();
const writer = writable.getWriter();
const encoder = new TextEncoder();
let decoder = new TextDecoder("utf-8", {fatal: true});
let firstChunk = true;
let unsupportedCharset = false;
let partial = '';
let content = '';
try {
for(;;) {
const { done, value } = await reader.read();
if (done) {
if (partial.length) {
partial = await modifyHtmlChunk(partial, request, event, embedStylesheet);
await writer.write(encoder.encode(partial));
partial = '';
}
break;
}
let chunk = null;
if (unsupportedCharset) {
// Pass the data straight through
await writer.write(value);
continue;
} else {
try {
chunk = decoder.decode(value, {stream:true});
} catch (e) {
// Decoding failed, switch to passthrough
unsupportedCharset = true;
if (partial.length) {
await writer.write(encoder.encode(partial));
partial = '';
}
await writer.write(value);
continue;
}
}
try {
// Look inside of the first chunk for a HTML charset or content-type meta tag.
if (firstChunk) {
firstChunk = false;
if (chunkContainsInvalidCharset(chunk)) {
// switch to passthrough
unsupportedCharset = true;
if (partial.length) {
await writer.write(encoder.encode(partial));
partial = '';
}
await writer.write(value);
continue;
}
}
// TODO: Optimize this so we aren't continuously adding strings together
content = partial + chunk;
partial = '';
// See if there is an unclosed link tag at the end (and if so, carve it out
// to complete when the remainder comes in).
// This isn't perfect (case sensitive and doesn't allow whitespace in the tag)
// but it is good enough for our purpose and much faster than a regex.
const linkPos = content.lastIndexOf('<link');
if (linkPos >= 0) {
const linkClose = content.indexOf('/>', linkPos);
if (linkClose === -1) {
partial = content.slice(linkPos);
content = content.slice(0, linkPos);
}
}
if (content.length) {
content = await modifyHtmlChunk(content, request, event, embedStylesheet);
}
} catch (e) {
// Ignore the exception
}
if (content.length) {
await writer.write(encoder.encode(content));
content = '';
}
}
} catch(e) {
// Ignore the exception
}
try {
await writer.close();
} catch(e) {
// Ignore the exception
}
}
async function modifyHtmlChunk(content, request, event, embedStylesheet) {
// Fully tokenizing and parsing the HTML is expensive. This regex is much faster and should be reasonably safe.
// It looks for Stylesheet links for the Google fonts css and extracts the URL as match #1. It shouldn't match
// in-text content because the < > brackets would be escaped in the HTML. There is some potential risk of
// matching it in an inline script (unlikely but possible).
const fontCSSRegex = /<link\s+[^>]*href\s*=\s*['"]((https?:)?\/\/fonts.googleapis.com\/css[^'"]+)[^>]*>/mgi;
let match = fontCSSRegex.exec(content);
while (match !== null) {
const matchString = match[0];
if (matchString.indexOf('stylesheet') >= 0) {
if (embedStylesheet) {
const fontCSS = await fetchCSS(match[1], request, event);
if (fontCSS.length) {
// See if there is a media type on the link tag
let mediaStr = '';
const mediaMatch = matchString.match(/media\s*=\s*['"][^'"]*['"]/mig);
if (mediaMatch) {
mediaStr = ' ' + mediaMatch[0];
}
// Replace the actual css
let cssString = "<style" + mediaStr + ">\n";
cssString += fontCSS;
cssString += "\n</style>\n";
content = content.split(matchString).join(cssString);
fontCSSRegex.lastIndex -= matchString.length - cssString.length;
}
} else {
// Rewrite the URL to proxy it through the origin
let originalUrl = match[1];
let startPos = originalUrl.indexOf('/fonts.googleapis.com');
let newUrl = originalUrl.substr(startPos);
let newString = matchString.split(originalUrl).join(newUrl);
content = content.split(matchString).join(newString);
fontCSSRegex.lastIndex -= matchString.length - newString.length;
}
match = fontCSSRegex.exec(content);
}
}
return content;
}
var FONT_CACHE = {};
async function fetchCSS(url, request) {
let fontCSS = "";
if (url.startsWith('/'))
url = 'https:' + url;
const userAgent = request.headers.get('user-agent');
const clientAddr = request.headers.get('cf-connecting-ip');
const browser = getCacheKey(userAgent);
const cacheKey = browser ? url + '&' + browser : url;
const cacheKeyRequest = new Request(cacheKey);
let cache = null;
let foundInCache = false;
if (cacheKey in FONT_CACHE) {
// hit in the memory cache
fontCSS = FONT_CACHE[cacheKey];
foundInCache = true;
} else {
// Try pulling it from the cache API (wrap it in case it's not implemented)
try {
cache = caches.default;
let response = await cache.match(cacheKeyRequest);
if (response) {
fontCSS = await response.text();
foundInCache = true;
}
} catch(e) {
// Ignore the exception
}
}
if (!foundInCache) {
let headers = {'Referer': request.url};
if (browser) {
headers['User-Agent'] = userAgent;
} else {
headers['User-Agent'] = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)";
}
if (clientAddr) {
headers['X-Forwarded-For'] = clientAddr;
}
try {
const response = await fetch(url, {headers: headers});
if (response && response.status === 200) {
fontCSS = await response.text();
// Rewrite all of the font URLs to come through the worker
fontCSS = fontCSS.replace(/(https?:)?\/\/fonts\.gstatic\.com\//mgi, '/fonts.gstatic.com/');
// Add the css info to the font caches
FONT_CACHE[cacheKey] = fontCSS;
try {
if (cache) {
const cacheResponse = new Response(fontCSS, {ttl: 86400});
event.waitUntil(cache.put(cacheKeyRequest, cacheResponse));
}
} catch(e) {
// Ignore the exception
}
}
} catch(e) {
// Ignore the exception
}
}
return fontCSS;
}
function getCacheKey(userAgent) {
let os = '';
const osRegex = /^[^(]*\(\s*(\w+)/mgi;
let match = osRegex.exec(userAgent);
if (match) {
os = match[1];
}
let mobile = '';
if (userAgent.match(/Mobile/mgi)) {
mobile = 'Mobile';
}
// Detect Edge first since it includes Chrome and Safari
const edgeRegex = /\s+Edge\/(\d+)/mgi;
match = edgeRegex.exec(userAgent);
if (match) {
return 'Edge' + match[1] + os + mobile;
}
// Detect Chrome next (and browsers using the Chrome UA/engine)
const chromeRegex = /\s+Chrome\/(\d+)/mgi;
match = chromeRegex.exec(userAgent);
if (match) {
return 'Chrome' + match[1] + os + mobile;
}
// Detect Safari and Webview next
const webkitRegex = /\s+AppleWebKit\/(\d+)/mgi;
match = webkitRegex.exec(userAgent.match);
if (match) {
return 'WebKit' + match[1] + os + mobile;
}
// Detect Firefox
const firefoxRegex = /\s+Firefox\/(\d+)/mgi;
match = firefoxRegex.exec(userAgent);
if (match) {
return 'Firefox' + match[1] + os + mobile;
}
return null;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment