diff --git a/src/app/assets.js b/src/app/assets.js new file mode 100644 index 0000000..6928ab6 --- /dev/null +++ b/src/app/assets.js @@ -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}`); + } +} diff --git a/src/app/assets.test.js b/src/app/assets.test.js new file mode 100644 index 0000000..fbde3e9 --- /dev/null +++ b/src/app/assets.test.js @@ -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" + ); + }); + }); + }); +});