Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CORS origin configuration in backend.yaml #314

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion artifacts/prisma_archive.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifacts/project_schema.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"type":"object","properties":{"registries":{"type":"object","additionalProperties":{"$ref":"#/definitions/RegistryConfig"}},"modules":{"type":"object","additionalProperties":{"$ref":"#/definitions/ProjectModuleConfig"}}},"additionalProperties":false,"required":["modules","registries"],"definitions":{"RegistryConfig":{"anyOf":[{"type":"object","properties":{"local":{"$ref":"#/definitions/RegistryConfigLocal"}},"additionalProperties":false,"required":["local"]},{"type":"object","properties":{"git":{"$ref":"#/definitions/RegistryConfigGit"}},"additionalProperties":false,"required":["git"]}]},"RegistryConfigLocal":{"type":"object","properties":{"directory":{"type":"string"},"isExternal":{"description":"If true, this will be treated like an external registry. This is\nimportant if multiple projects are using the same registry locally.\n\n Modules from this directory will not be tested, formatted, linted, and\n generate Prisma migrations.","type":"boolean"}},"additionalProperties":false,"required":["directory"]},"RegistryConfigGit":{"anyOf":[{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"branch":{"type":"string"}},"required":["branch","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"tag":{"type":"string"}},"required":["tag","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"rev":{"type":"string"}},"required":["rev","url"]}]},"RegistryConfigGitUrl":{"description":"The URL to the git repository.\n\nIf both HTTPS and SSH URL are provided, they will both be tried and use the\none that works.","anyOf":[{"type":"object","properties":{"https":{"type":"string"},"ssh":{"type":"string"}},"additionalProperties":false},{"type":"string"}]},"ProjectModuleConfig":{"type":"object","properties":{"registry":{"description":"The name of the registry to fetch the module from.","type":"string"},"module":{"description":"Overrides the name of the module to fetch inside the registry.","type":"string"},"config":{"description":"The config that configures how this module is ran at runtime."}},"additionalProperties":false}},"$schema":"http://json-schema.org/draft-07/schema#"}
{"type":"object","properties":{"registries":{"type":"object","additionalProperties":{"$ref":"#/definitions/RegistryConfig"}},"modules":{"type":"object","additionalProperties":{"$ref":"#/definitions/ProjectModuleConfig"}},"runtime":{"$ref":"#/definitions/RuntimeConfig"}},"additionalProperties":false,"required":["modules","registries"],"definitions":{"RegistryConfig":{"anyOf":[{"type":"object","properties":{"local":{"$ref":"#/definitions/RegistryConfigLocal"}},"additionalProperties":false,"required":["local"]},{"type":"object","properties":{"git":{"$ref":"#/definitions/RegistryConfigGit"}},"additionalProperties":false,"required":["git"]}]},"RegistryConfigLocal":{"type":"object","properties":{"directory":{"type":"string"},"isExternal":{"description":"If true, this will be treated like an external registry. This is\nimportant if multiple projects are using the same registry locally.\n\n Modules from this directory will not be tested, formatted, linted, and\n generate Prisma migrations.","type":"boolean"}},"additionalProperties":false,"required":["directory"]},"RegistryConfigGit":{"anyOf":[{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"branch":{"type":"string"}},"required":["branch","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"tag":{"type":"string"}},"required":["tag","url"]},{"additionalProperties":false,"type":"object","properties":{"url":{"$ref":"#/definitions/RegistryConfigGitUrl"},"directory":{"type":"string"},"rev":{"type":"string"}},"required":["rev","url"]}]},"RegistryConfigGitUrl":{"description":"The URL to the git repository.\n\nIf both HTTPS and SSH URL are provided, they will both be tried and use the\none that works.","anyOf":[{"type":"object","properties":{"https":{"type":"string"},"ssh":{"type":"string"}},"additionalProperties":false},{"type":"string"}]},"ProjectModuleConfig":{"type":"object","properties":{"registry":{"description":"The name of the registry to fetch the module from.","type":"string"},"module":{"description":"Overrides the name of the module to fetch inside the registry.","type":"string"},"config":{"description":"The config that configures how this module is ran at runtime."}},"additionalProperties":false},"RuntimeConfig":{"type":"object","properties":{"cors":{"description":"If this is null, only requests made from the same origin will be accepted","$ref":"#/definitions/CorsConfig"}},"additionalProperties":false},"CorsConfig":{"type":"object","properties":{"origins":{"description":"The origins that are allowed to make requests to the server.","type":"array","items":{"type":"string"}}},"additionalProperties":false,"required":["origins"]}},"$schema":"http://json-schema.org/draft-07/schema#"}
2 changes: 1 addition & 1 deletion artifacts/runtime_archive.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/build/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export async function generateEntrypoint(project: Project, opts: BuildOpts) {
throw new UnreachableError(opts.runtime);
}

let corsSource = "";
if (project.config.runtime?.cors) {
corsSource = `
cors: {
origins: new Set(${JSON.stringify(project.config.runtime.cors.origins)}),
},
`;
}

// Generate config.ts
const configSource = `
${autoGenHeader()}
Expand All @@ -67,6 +76,7 @@ export async function generateEntrypoint(project: Project, opts: BuildOpts) {
export default {
runtime: BuildRuntime.${runtimeToString(opts.runtime)},
modules: ${modConfig},
${corsSource}
} as Config;
`;

Expand Down
23 changes: 23 additions & 0 deletions src/build/inflate_runtime_archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import runtimeArchive from "../../artifacts/runtime_archive.json" with { type: "json" };
import { dirname, emptyDir, resolve } from "../deps.ts";
import { Project } from "../project/mod.ts";
import { genPath, RUNTIME_PATH } from "../project/project.ts";

/**
* Writes a copy of the OpenGB runtime bundled with the CLI to the project.
*/
export async function inflateRuntimeArchive(project: Project, signal?: AbortSignal) {
signal?.throwIfAborted();

const inflateRuntimePath = genPath(project, RUNTIME_PATH);

await emptyDir(inflateRuntimePath);

for (const [file, value] of Object.entries(runtimeArchive)) {
signal?.throwIfAborted();

const absPath = resolve(inflateRuntimePath, file);
await Deno.mkdir(dirname(absPath), { recursive: true });
await Deno.writeTextFile(absPath, value);
}
}
15 changes: 15 additions & 0 deletions src/config/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InternalError } from "../error/mod.ts";
export interface ProjectConfig extends Record<string, unknown> {
registries: { [name: string]: RegistryConfig };
modules: { [name: string]: ProjectModuleConfig };
runtime?: RuntimeConfig;
}

export type RegistryConfig = { local: RegistryConfigLocal } | { git: RegistryConfigGit };
Expand Down Expand Up @@ -52,6 +53,20 @@ export interface ProjectModuleConfig extends Record<string, unknown> {
config?: any;
}

export interface RuntimeConfig {
/**
* If this is null, only requests made from the same origin will be accepted
*/
cors?: CorsConfig;
}

export interface CorsConfig {
/**
* The origins that are allowed to make requests to the server.
*/
origins: string[];
}

// export async function readConfig(path: string): Promise<ProjectConfig> {
// const configRaw = await Deno.readTextFile(path);
// return parse(configRaw) as ProjectConfig;
Expand Down
70 changes: 70 additions & 0 deletions src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ActorDriver } from "./actor.ts";
export interface Config {
runtime: BuildRuntime;
modules: Record<string, Module>;
cors?: CorsConfig;
}

/**
Expand All @@ -33,6 +34,10 @@ export interface Module {
userConfig: unknown;
}

export interface CorsConfig {
origins: Set<string>;
}

interface CreatePrismaOutput {
prisma: PrismaClientDummy;
pgPool?: any;
Expand Down Expand Up @@ -175,4 +180,69 @@ export class Runtime<DependenciesSnakeT, DependenciesCamelT, ActorsSnakeT, Actor
},
});
}

/**
* Only runs on a CORS preflight request— returns a response with the
* appropriate CORS headers & status.
*
* @param req The preflight OPTIONS request
* @returns The full response to the preflight request
*/
public corsPreflight(req: Request): Response {
const origin = req.headers.get("Origin");
if (origin) {
const normalizedOrigin = new URL(origin).origin;
if (this.config.cors) {
if (this.config.cors.origins.has(normalizedOrigin)) {
return new Response(undefined, {
status: 204,
headers: {
...this.corsHeaders(req),
"Vary": "Origin",
},
});
}
}
}

// Origin is not allowed/no origin header on preflight
return new Response(
JSON.stringify({
"message": "CORS origin not allowed. See https://opengb.dev/docs/cors",
}),
{
status: 403,
headers: {
"Vary": "Origin",
},
},
);
}

public corsHeaders(req: Request): Record<string, string> {
const origin = req.headers.get("Origin");

// Don't set CORS headers if there's no origin (e.g. a server-side
// request)
if (!origin) return {};

// If the origin is allowed, return the appropriate headers.
// Otherwise, return a non-matching cors header (empty object).
if (this.config.cors?.origins.has(origin)) {
return {
"Access-Control-Allow-Origin": new URL(origin).origin,
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
};
} else {
return {};
}
}

public corsAllowed(req: Request): boolean {
const origin = req.headers.get("Origin");

if (!origin) return true;
return this.config.cors?.origins.has(origin) ?? false;
}
}
195 changes: 118 additions & 77 deletions src/runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,93 +13,134 @@ export async function handleRequest<DependenciesSnakeT, DependenciesCamelT, Acto
): Promise<Response> {
const url = new URL(req.url);

const matches = MODULE_CALL.exec(url.pathname);
if (req.method == "POST" && matches?.groups) {
// Lookup script
const moduleName = matches.groups.module;
const scriptName = matches.groups.script;
const script = runtime.config.modules[moduleName]?.scripts[scriptName];
// Handle CORS preflight
if (req.method === "OPTIONS") {
return runtime.corsPreflight(req);
}

// Disallow even simple requests if CORS is not allowed
if (!runtime.corsAllowed(req)) {
return new Response(undefined, {
status: 403,
headers: {
"Vary": "Origin",
...runtime.corsHeaders(req),
},
});
}

// Only allow POST requests
if (req.method !== "POST") {
return new Response(undefined, {
status: 405,
headers: {
"Allow": "POST",
...runtime.corsHeaders(req),
},
});
}

if (script?.public) {
// Create context
const ctx = runtime.createRootContext({
httpRequest: {
method: req.method,
path: url.pathname,
remoteAddress: info.remoteAddress,
headers: Object.fromEntries(req.headers.entries()),
// Get module and script name
const matches = MODULE_CALL.exec(url.pathname);
if (!matches?.groups) {
return new Response(
JSON.stringify({
"message": "Route not found. Make sure the URL and method are correct.",
}),
{
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
});
status: 404,
},
);
}

// Parse body
let body;
try {
body = await req.json();
} catch {
const output = {
message: "Request must have a valid JSON body.",
};
return new Response(JSON.stringify(output), {
status: 400,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
// Lookup script
const moduleName = matches.groups.module;
const scriptName = matches.groups.script;
const script = runtime.config.modules[moduleName]?.scripts[scriptName];

// Confirm script exists and is public
if (!script || !script.public) {
return new Response(
JSON.stringify({
"message": "Route not found. Make sure the URL and method are correct.",
}),
{
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
status: 404,
},
);
}

try {
// Call module
const output = await ctx.call(
moduleName as any,
scriptName as any,
body,
);
// Create context
const ctx = runtime.createRootContext({
httpRequest: {
method: req.method,
path: url.pathname,
remoteAddress: info.remoteAddress,
headers: Object.fromEntries(req.headers.entries()),
},
});

if (output.__tempPleaseSeeOGBE3_NoData) {
return new Response(undefined, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
},
});
}
// Parse body
let body;
try {
body = await req.json();
} catch {
const output = {
message: "Request must have a valid JSON body.",
};
return new Response(JSON.stringify(output), {
status: 400,
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
});
}

return new Response(JSON.stringify(output), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
} catch (e) {
// Error response
const output = {
message: e.message,
stack: e.stack,
};
try {
// Call module
const output = await ctx.call(
moduleName as any,
scriptName as any,
body,
);

return new Response(JSON.stringify(output), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
if (output.__tempPleaseSeeOGBE3_NoData) {
return new Response(undefined, {
status: 204,
headers: {
...runtime.corsHeaders(req),
},
});
}
}

// Not found response
return new Response(
JSON.stringify({
"message": "Route not found. Make sure the URL and method are correct.",
}),
{
return new Response(JSON.stringify(output), {
status: 200,
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
status: 404,
},
);
});
} catch (e) {
// Error response
const output = {
message: e.message,
};

return new Response(JSON.stringify(output), {
status: 500,
headers: {
"Content-Type": "application/json",
...runtime.corsHeaders(req),
},
});
}
}