Add an `Assets` resolver (#664)

Summary:
This will enable clients to obtain the path to a static asset, even when
the app is not hosted at the root of a server, as outlined in #643.

This module will be used for simple assets (images, etc.) and API data
(fetches from `/api/**`) alike.

This supersedes #663. It includes the logic from that PR (`Assets`)
without the React-specific context bindings (`AssetsContext`).

Test Plan:
Unit tests included; `yarn test` suffices.

wchargin-branch: assets-resolver
This commit is contained in:
William Chargin 2018-08-15 12:46:09 -07:00 committed by GitHub
parent ad0e98ac2c
commit c1997d041f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 206 additions and 0 deletions

42
src/app/assets.js Normal file
View File

@ -0,0 +1,42 @@
// @flow
import normalize from "../util/pathNormalize";
/**
* Resolver for static assets (e.g., images, PDFs) and API data (e.g.,
* the repository registry, plugin data). Any references to resources
* should be resolved through this API.
*/
export class Assets {
+_root: ?string;
/**
* Construct a resolver given a path to the root of the site. This can
* be a relative path, like `../..`, or an absolute path, like `/`.
*/
constructor(root: ?string) {
this._root = root == null ? root : normalize(root);
}
_getRoot(): string {
if (this._root == null) {
throw new Error("asset root path uninitialized");
}
return this._root;
}
/**
* Resolve a path as if the current directory and "/" both represent
* the site root. For instance, "foo", "/foo", and "./foo" all
* represent the same file. It is an error to specify a file that is
* above the root, like "../bad".
*/
resolve(path: string) {
if (normalize(path.replace(/^\/+/, "")).startsWith("..")) {
// It doesn't make sense to traverse past the site's root. This is
// likely an error in the caller.
throw new Error("path outside site root: " + path);
}
return normalize(`${this._getRoot()}/${path}`);
}
}

164
src/app/assets.test.js Normal file
View File

@ -0,0 +1,164 @@
// @flow
import {Assets} from "./assets";
describe("app/assets", () => {
describe("Assets", () => {
describe("with an unknown root path (null)", () => {
it("can be constructed", () => {
const _: Assets = new Assets(null);
});
it("fails to resolve anything", () => {
for (const x of ["", ".", "foo.png", "/foo.png", "/foo/bar/"]) {
expect(() => new Assets(null).resolve(x)).toThrowError(
"asset root path uninitialized"
);
}
});
});
describe("with an empty root path", () => {
it("can be constructed", () => {
const _: Assets = new Assets("");
});
it('resolves the root path itself using "."', () => {
const assets = new Assets("");
expect(assets.resolve(".")).toEqual(".");
});
it('resolves the root directory using "./"', () => {
const assets = new Assets("");
expect(assets.resolve("./")).toEqual("./");
});
it('resolves the root directory using ""', () => {
const assets = new Assets("");
expect(assets.resolve("")).toEqual("./");
});
it('resolves an implicitly relative filename ("favicon.png")', () => {
const assets = new Assets("");
expect(assets.resolve("favicon.png")).toEqual("favicon.png");
});
it('resolves an explicitly relative filename ("./favicon.png")', () => {
const assets = new Assets("");
expect(assets.resolve("./favicon.png")).toEqual("favicon.png");
});
it('resolves a file by absolute filename ("/favicon.png")', () => {
const assets = new Assets("");
expect(assets.resolve("/favicon.png")).toEqual("favicon.png");
});
it("errors when given an implicitly relative path above root", () => {
const assets = new Assets("");
expect(() => assets.resolve("../foo")).toThrow(
"path outside site root: ../foo"
);
});
it("errors when given an explicitly relative path above root", () => {
const assets = new Assets("");
expect(() => assets.resolve("./../foo")).toThrow(
"path outside site root: ./../foo"
);
});
it("errors when given an absolute path above root", () => {
const assets = new Assets("");
expect(() => assets.resolve("/../foo")).toThrow(
"path outside site root: /../foo"
);
});
});
describe('with a relative root path ("../..")', () => {
it("can be constructed", () => {
const _: Assets = new Assets("../..");
});
it('resolves the root path itself using "."', () => {
const assets = new Assets("../..");
expect(assets.resolve(".")).toEqual("../..");
});
it('resolves the root directory using "./"', () => {
const assets = new Assets("../..");
expect(assets.resolve("./")).toEqual("../../");
});
it('resolves the root directory using ""', () => {
const assets = new Assets("../..");
expect(assets.resolve("")).toEqual("../../");
});
it('resolves an implicitly relative filename ("favicon.png")', () => {
const assets = new Assets("../..");
expect(assets.resolve("favicon.png")).toEqual("../../favicon.png");
});
it('resolves an explicitly relative filename ("./favicon.png")', () => {
const assets = new Assets("../..");
expect(assets.resolve("./favicon.png")).toEqual("../../favicon.png");
});
it('resolves a file by absolute filename ("/favicon.png")', () => {
const assets = new Assets("../..");
expect(assets.resolve("/favicon.png")).toEqual("../../favicon.png");
});
it("errors when given an implicitly relative path above root", () => {
const assets = new Assets("../..");
expect(() => assets.resolve("../foo")).toThrow(
"path outside site root: ../foo"
);
});
it("errors when given an explicitly relative path above root", () => {
const assets = new Assets("../..");
expect(() => assets.resolve("./../foo")).toThrow(
"path outside site root: ./../foo"
);
});
it("errors when given an absolute path above root", () => {
const assets = new Assets("../..");
expect(() => assets.resolve("/../foo")).toThrow(
"path outside site root: /../foo"
);
});
});
describe('with an absolute root path ("/ab/cd/")', () => {
it("can be constructed", () => {
const _: Assets = new Assets("/ab/cd/");
});
it('resolves the root path itself using "."', () => {
const assets = new Assets("/ab/cd/");
expect(assets.resolve(".")).toEqual("/ab/cd");
});
it('resolves the root directory using "./"', () => {
const assets = new Assets("/ab/cd/");
expect(assets.resolve("./")).toEqual("/ab/cd/");
});
it('resolves the root directory using ""', () => {
const assets = new Assets("/ab/cd/");
expect(assets.resolve("")).toEqual("/ab/cd/");
});
it('resolves an implicitly relative filename ("favicon.png")', () => {
const assets = new Assets("/ab/cd/");
expect(assets.resolve("favicon.png")).toEqual("/ab/cd/favicon.png");
});
it('resolves an explicitly relative filename ("./favicon.png")', () => {
const assets = new Assets("/ab/cd/");
expect(assets.resolve("./favicon.png")).toEqual("/ab/cd/favicon.png");
});
it('resolves a file by absolute filename ("/favicon.png")', () => {
const assets = new Assets("/ab/cd/");
expect(assets.resolve("/favicon.png")).toEqual("/ab/cd/favicon.png");
});
it("errors when given an implicitly relative path above root", () => {
const assets = new Assets("/ab/cd/");
expect(() => assets.resolve("../foo")).toThrow(
"path outside site root: ../foo"
);
});
it("errors when given an explicitly relative path above root", () => {
const assets = new Assets("/ab/cd/");
expect(() => assets.resolve("./../foo")).toThrow(
"path outside site root: ./../foo"
);
});
it("errors when given an absolute path above root", () => {
const assets = new Assets("/ab/cd/");
expect(() => assets.resolve("/../foo")).toThrow(
"path outside site root: /../foo"
);
});
});
});
});