Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions handleRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,17 @@ it("should not redirect wasm plugins", async () => {
expect(response.status).not.toEqual(302);
});

it("should redirect non-allowed org asset to GitHub", async () => {
const { handleRequest } = createRequestHandler();
const response = await handleRequest(
new Request("https://plugins.dprint.dev/someone/some-repo/0.1.0/asset/file.zip"),
);
expect(response.status).toEqual(302);
expect(response.headers.get("location")).toEqual(
"https://github.com/someone/some-repo/releases/download/0.1.0/file.zip",
);
});

it("should return 404 for asset not found", async () => {
const { handleRequest } = createRequestHandler();
const response = await handleRequest(
Expand Down
61 changes: 38 additions & 23 deletions handleRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { renderHome } from "./home.jsx";
import oldMappings from "./old_redirects.json" with { type: "json" };
import { tryResolveAssetUrl, tryResolveLatestJson, tryResolvePluginUrl, tryResolveSchemaUrl } from "./plugins.js";
import {
tryResolveAssetUrl,
tryResolveLatestJson,
tryResolvePluginUrl,
tryResolveSchemaUrl,
} from "./plugins.js";
import { readInfoFile } from "./readInfoFile.js";
import robotsTxt from "./robots.txt";
import styleCSS from "./style.css";
Expand All @@ -24,9 +29,12 @@ export function createRequestHandler() {
return {
async handleRequest(request: Request, ctx?: ExecutionContext) {
const url = new URL(request.url);
const assetUrl = tryResolveAssetUrl(url);
if (assetUrl != null) {
return servePlugin(request, assetUrl, ctx);
const assetResult = tryResolveAssetUrl(url);
if (assetResult != null) {
if (!assetResult.shouldCache) {
return Response.redirect(assetResult.githubUrl, 302);
}
return servePlugin(request, assetResult.githubUrl, ctx);
}

const githubUrl = await resolvePluginOrSchemaUrl(url);
Expand All @@ -36,7 +44,7 @@ export function createRequestHandler() {
// plugin.json files resolve correctly
const assetPath = githubUrlToAssetPath(githubUrl);
if (assetPath != null) {
return Response.redirect(new URL(assetPath, url.origin).href, 302);
return Response.redirect(`${url.origin}${assetPath}`, 302);
}
}
return servePlugin(request, githubUrl, ctx);
Expand All @@ -57,7 +65,7 @@ export function createRequestHandler() {
}

if (url.pathname.startsWith("/info.json")) {
const infoFileData = await readInfoFile();
const infoFileData = await readInfoFile(url.origin);
return createJsonResponse(
JSON.stringify(infoFileData, null, 2),
request,
Expand Down Expand Up @@ -108,7 +116,7 @@ export function createRequestHandler() {
};

async function servePlugin(request: Request, githubUrl: string, ctx?: ExecutionContext) {
const result = await resolveBody(githubUrl, ctx);
const result = await resolveBodyWithMemoryCache(githubUrl, ctx);
return new Response(result.body, {
headers: {
"content-type": result.contentType,
Expand All @@ -118,28 +126,37 @@ export function createRequestHandler() {
});
}

async function resolveBody(
async function resolveBodyWithMemoryCache(
githubUrl: string,
ctx?: ExecutionContext,
): Promise<{ body: ArrayBuffer | ReadableStream | null; status: number; contentType: string }> {
// L1: in-memory cache
// L1: in-memory cache (already rewritten)
const cached = memoryCache.get(githubUrl);
if (cached != null) {
return { body: cached.body, status: 200, contentType: cached.contentType };
}

// L2: R2
const result = await fetchBody(githubUrl, ctx);
if (result.status === 200 && result.body instanceof ArrayBuffer && result.body.byteLength <= MAX_MEM_CACHE_BODY_SIZE) {
memoryCache.set(githubUrl, { body: result.body, contentType: result.contentType });
}

return result;
}

async function fetchBody(
githubUrl: string,
ctx?: ExecutionContext,
): Promise<{ body: ArrayBuffer | ReadableStream | null; status: number; contentType: string }> {
// L2: R2 (stores original content)
const r2Object = await r2Get(githubUrl);
if (r2Object != null) {
const r2ContentType = r2Object.httpMetadata?.contentType ?? contentTypeForUrl(githubUrl);
// small enough for L1 — buffer and cache
const contentType = r2Object.httpMetadata?.contentType ?? contentTypeForUrl(githubUrl);
if (r2Object.size <= MAX_MEM_CACHE_BODY_SIZE) {
const buffer = await r2Object.arrayBuffer();
memoryCache.set(githubUrl, { body: buffer, contentType: r2ContentType });
return { body: buffer, status: 200, contentType: r2ContentType };
return { body: await r2Object.arrayBuffer(), status: 200, contentType };
}
// large — stream directly without buffering
return { body: r2Object.body, status: 200, contentType: r2ContentType };
return { body: r2Object.body, status: 200, contentType };
}

// L3: fetch from GitHub
Expand All @@ -155,21 +172,18 @@ export function createRequestHandler() {
};
}

const responseContentType = response.headers.get("content-type") ?? contentTypeForUrl(githubUrl);
const contentType = response.headers.get("content-type") ?? contentTypeForUrl(githubUrl);
const body = await response.arrayBuffer();

// populate caches
const r2Promise = r2Put(githubUrl, body, responseContentType);
// store original in R2
const r2Promise = r2Put(githubUrl, body, contentType);
if (ctx != null) {
ctx.waitUntil(r2Promise);
} else {
await r2Promise;
}
if (body.byteLength <= MAX_MEM_CACHE_BODY_SIZE) {
memoryCache.set(githubUrl, { body, contentType: responseContentType });
}

return { body, status: 200, contentType: responseContentType };
return { body, status: 200, contentType };
}
}

Expand Down Expand Up @@ -213,6 +227,7 @@ function githubUrlToAssetPath(githubUrl: string) {
return `/${username}/${repo}/${tag}/asset/${fileName}`;
}


function contentTypeForUrl(url: string) {
if (url.endsWith(".wasm")) return contentTypes.wasm;
if (url.endsWith(".json") || url.endsWith(".exe-plugin")) return contentTypes.json;
Expand Down
34 changes: 22 additions & 12 deletions plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,43 @@ function resolveAsset(url: string) {
}

it("tryResolveAssetUrl", () => {
// allowed repo
// allowed repo — should cache
expect(
resolveAsset(
"https://plugins.dprint.dev/dprint/dprint-plugin-prettier/0.67.0/asset/dprint-plugin-prettier-x86_64-apple-darwin.zip",
),
).toEqual(
"https://github.com/dprint/dprint-plugin-prettier/releases/download/0.67.0/dprint-plugin-prettier-x86_64-apple-darwin.zip",
);
).toEqual({
githubUrl:
"https://github.com/dprint/dprint-plugin-prettier/releases/download/0.67.0/dprint-plugin-prettier-x86_64-apple-darwin.zip",
shouldCache: true,
});

// latest tag is not allowed
// latest tag — still resolves but not cached
expect(
resolveAsset(
"https://plugins.dprint.dev/dprint/dprint-plugin-prettier/latest/asset/dprint-plugin-prettier-x86_64-apple-darwin.zip",
),
).toEqual(undefined);
).toEqual({
githubUrl:
"https://github.com/dprint/dprint-plugin-prettier/releases/download/latest/dprint-plugin-prettier-x86_64-apple-darwin.zip",
shouldCache: true,
});

// different repo in dprint org also works
// different repo in dprint org — should cache
expect(
resolveAsset("https://plugins.dprint.dev/dprint/dprint-plugin-exec/0.5.0/asset/some-binary.zip"),
).toEqual(
"https://github.com/dprint/dprint-plugin-exec/releases/download/0.5.0/some-binary.zip",
);
).toEqual({
githubUrl: "https://github.com/dprint/dprint-plugin-exec/releases/download/0.5.0/some-binary.zip",
shouldCache: true,
});

// org not on allow list
// org not on allow list — resolves but should not cache (redirect)
expect(
resolveAsset("https://plugins.dprint.dev/someone/some-repo/0.1.0/asset/file.zip"),
).toEqual(undefined);
).toEqual({
githubUrl: "https://github.com/someone/some-repo/releases/download/0.1.0/file.zip",
shouldCache: false,
});

// non-matching URL
expect(
Expand Down
32 changes: 16 additions & 16 deletions plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,32 +56,32 @@ const KNOWN_NON_PREFIXED_REPOS = new Set([
"lucacasonato/mf2-tools",
]);

// orgs/users allowed to serve release assets directly
const ASSET_ALLOWED_ORGS = new Set([
"dprint",
]);
export function isAssetAllowedRepo(username: string, _repo: string) {
switch (username) {
case "dprint":
return true;
default:
return false;
}
}

const assetNamePattern = "([A-Za-z0-9\\-\\._]+)";
const assetPattern = new URLPattern({
pathname: `/${userRepoPattern}/${tagPattern}/asset/${assetNamePattern}`,
});

export function tryResolveAssetUrl(url: URL) {
export function tryResolveAssetUrl(url: URL): { githubUrl: string; shouldCache: boolean } | undefined {
const result = assetPattern.exec(url);
if (!result) {
return undefined;
}
const username = result.pathname.groups[0]!;
const repo = result.pathname.groups[1]!;
if (!ASSET_ALLOWED_ORGS.has(username)) {
return undefined;
}
const tag = result.pathname.groups[2]!;
if (tag === "latest") {
return undefined;
}
const assetName = result.pathname.groups[3]!;
return `https://github.com/${username}/${repo}/releases/download/${tag}/${assetName}`;
const githubUrl = `https://github.com/${username}/${repo}/releases/download/${tag}/${assetName}`;
const shouldCache = isAssetAllowedRepo(username, repo);
return { githubUrl, shouldCache };
}

export async function tryResolvePluginUrl(url: URL) {
Expand All @@ -105,7 +105,7 @@ export async function tryResolveLatestJson(url: URL) {
}
const username = result.pathname.groups[0]!;
const shortRepoName = result.pathname.groups[1]!;
const latestInfo = await getLatestInfo(username, shortRepoName);
const latestInfo = await getLatestInfo(username, shortRepoName, url.origin);
if (latestInfo == null) {
return 404;
}
Expand All @@ -120,7 +120,7 @@ export async function tryResolveLatestJson(url: URL) {
};
}

export async function getLatestInfo(username: string, repoName: string) {
export async function getLatestInfo(username: string, repoName: string, origin: string) {
repoName = await getFullRepoName(username, repoName);
const releaseInfo = await getLatestReleaseInfo(username, repoName);
if (releaseInfo == null) {
Expand All @@ -134,8 +134,8 @@ export async function getLatestInfo(username: string, repoName: string) {
return {
schemaVersion: 1,
url: username === "dprint"
? `https://plugins.dprint.dev/${displayRepoName}-${releaseInfo.tagName}.${extension}`
: `https://plugins.dprint.dev/${username}/${displayRepoName}-${releaseInfo.tagName}.${extension}`,
? `${origin}/${displayRepoName}-${releaseInfo.tagName}.${extension}`
: `${origin}/${username}/${displayRepoName}-${releaseInfo.tagName}.${extension}`,
version: releaseInfo.tagName.replace(/^v/, ""),
checksum: releaseInfo.checksum,
downloadCount: releaseInfo.downloadCount,
Expand Down
6 changes: 3 additions & 3 deletions readInfoFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface PluginData {
};
}

export async function readInfoFile(): Promise<Readonly<PluginsData>> {
export async function readInfoFile(origin: string): Promise<Readonly<PluginsData>> {
return {
...infoJson,
latest: await getLatest(infoJson.latest),
Expand All @@ -28,8 +28,8 @@ export async function readInfoFile(): Promise<Readonly<PluginsData>> {
for (const plugin of latest) {
const [username, pluginName] = plugin.name.split("/");
const info = pluginName
? await getLatestInfo(username, pluginName)
: await getLatestInfo("dprint", plugin.name);
? await getLatestInfo(username, pluginName, origin)
: await getLatestInfo("dprint", plugin.name, origin);
if (info != null) {
results.push({
...plugin,
Expand Down
Loading