Repository: solid/community-server (ts)

Issue:
Implement temporary index representation solution
The [behavior of NSS](https://github.com/solid/node-solid-server/blob/v5.6.7/lib/resource-mapper.js#L130-L137) is that, if a container `/foo/` is requested and an `index.html` file exist on disk in that folder, then `/foo/index.html` is served.

There are a couple of conceptual (https://github.com/solid/specification/issues/198, https://github.com/solid/specification/issues/109, https://github.com/solid/specification/issues/69#issuecomment-563455568) and access control (https://github.com/solid/web-access-control-spec/issues/36) issues with that solution, and we can only properly implement this when consensus has been reached there.

However, in absence of such consensus, I propose to add a hard-coded solution where a store checks if `index.html` is available and serves it if it is, disregarding any access control and other concerns.

The desired fallback sequence is:
1. `/foo/index.html`
2. default UI (Mashlib) if configured
3. container listing

My proposal is to:

- [ ] Implement an `IndexRepresentationStore` 
  - takes a `source` argument of the store to get index from
  - takes an `indexName` argument that defaults to `index.html`
  - probably also takes an identifier strategy or so as argument
  - does not handle non-containers
  - handles containers _if_ that container contains an `index.html`
  - serves a container's `index.html`
- [ ] Rename `IndexConverter` to `DefaultUiConverter` in config
- [ ] Configure the `IndexRepresentationStore` in `config/util/index/default.json`
  - [ ] keep fallback to `DefaultUiConverter` open; `index.html` has precedence over `IndexRepresentationStore`

Note that the choice regarding handling `IndexRepresentationStore` _not_ handling anything else is such that we can deliver the correct fallback. If `IndexRepresentationStore` would instead fall back to the `source` if there is no `index.html`, then the UI would never be served.

Pull request description:
Add support for index.html resources
Closes #800 .

Unfortunately implementing this as a converter is impossible since that would create a circular dependency between the converter and the store.

The main reason I would have preferred a converter can be seen in the config changes. Adding a store to the stack of stores requires more changes. This did make me think that perhaps we should split up the store configs into 2 features since the first part is the same for all stores.

Patch:
diff --git a/config/storage/resource-store/dynamic.json b/config/storage/resource-store/dynamic.json
index 35601dde3..f36570e2e 100644
--- a/config/storage/resource-store/dynamic.json
+++ b/config/storage/resource-store/dynamic.json
@@ -14,14 +14,17 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_Routing",
-              "comment": "A router rule will be defined when activating dynamic pods."
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_Routing",
+                "comment": "A router rule will be defined when activating dynamic pods."
+              }
             }
           }
         }
diff --git a/config/storage/resource-store/file.json b/config/storage/resource-store/file.json
index ed1741165..2cfcce1a0 100644
--- a/config/storage/resource-store/file.json
+++ b/config/storage/resource-store/file.json
@@ -13,15 +13,18 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
-              "DataAccessorBasedStore:_accessor": {
-                "@id": "urn:solid-server:default:FileDataAccessor"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
+                "DataAccessorBasedStore:_accessor": {
+                  "@id": "urn:solid-server:default:FileDataAccessor"
+                }
               }
             }
           }
diff --git a/config/storage/resource-store/memory.json b/config/storage/resource-store/memory.json
index 0801531b8..cdd84fdc3 100644
--- a/config/storage/resource-store/memory.json
+++ b/config/storage/resource-store/memory.json
@@ -13,15 +13,18 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
-              "DataAccessorBasedStore:_accessor": {
-                "@id": "urn:solid-server:default:MemoryDataAccessor"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
+                "DataAccessorBasedStore:_accessor": {
+                  "@id": "urn:solid-server:default:MemoryDataAccessor"
+                }
               }
             }
           }
diff --git a/config/storage/resource-store/regex.json b/config/storage/resource-store/regex.json
index 0b9ea6533..19290ac19 100644
--- a/config/storage/resource-store/regex.json
+++ b/config/storage/resource-store/regex.json
@@ -15,13 +15,16 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-      "@id": "urn:solid-server:default:ResourceStore_Locking",
-      "LockingResourceStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Patching",
-        "PatchingStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Converting",
-          "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_Routing"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_Routing"
+              }
             }
           }
         }
diff --git a/config/storage/resource-store/sparql.json b/config/storage/resource-store/sparql.json
index 2b4fd5b8b..da64f60fd 100644
--- a/config/storage/resource-store/sparql.json
+++ b/config/storage/resource-store/sparql.json
@@ -13,18 +13,23 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "comment": "This makes it so all incoming data is converted to quad objects.",
-            "RepresentationConvertingStore:_options_inConverter": { "@id": "urn:solid-server:default:RepresentationConverter" },
-            "RepresentationConvertingStore:_options_inType": "internal/quads",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
-              "DataAccessorBasedStore:_accessor": {
-                "@id": "urn:solid-server:default:SparqlDataAccessor"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "comment": "This makes it so all incoming data is converted to quad objects.",
+              "RepresentationConvertingStore:_options_inConverter": {
+                "@id": "urn:solid-server:default:RepresentationConverter"
+              },
+              "RepresentationConvertingStore:_options_inType": "internal/quads",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
+                "DataAccessorBasedStore:_accessor": {
+                  "@id": "urn:solid-server:default:SparqlDataAccessor"
+                }
               }
             }
           }
diff --git a/config/util/index/default.json b/config/util/index/default.json
index a771ddd84..b3dcb3535 100644
--- a/config/util/index/default.json
+++ b/config/util/index/default.json
@@ -1,9 +1,14 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "@graph": [
+    {
+      "comment": "When a container with an index.html document is accessed, serve that HTML document instead of the container.",
+      "@id": "urn:solid-server:default:ResourceStore_Index",
+      "@type": "IndexRepresentationStore"
+    },
     {
       "comment": "This value can be used to set a custom handler for index files. See the example file.",
-      "@id": "urn:solid-server:default:IndexConverter",
+      "@id": "urn:solid-server:default:DefaultUiConverter",
       "@type": "UnsupportedAsyncHandler"
     }
   ]
diff --git a/config/util/index/example.json b/config/util/index/example.json
index 7fdb4ca46..162902767 100644
--- a/config/util/index/example.json
+++ b/config/util/index/example.json
@@ -1,12 +1,17 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "@graph": [
+    {
+      "comment": "This an example on how to disable the routing to index.html when accessing a container.",
+      "@id": "urn:solid-server:default:ResourceStore_Index",
+      "@type": "PassthroughStore"
+    },
     {
       "comment": [
         "This converter replaces every browser request for text/html with the Databrowser UI (which then in turn loads a Turtle or other representation of the same resource).",
         "This is useful when you want to serve a UI for every file from the pod."
       ],
-      "@id": "urn:solid-server:default:IndexConverter",
+      "@id": "urn:solid-server:default:DefaultUiConverter",
       "@type": "ConstantConverter",
       "contentType": "text/html",
       "filePath": "./node_modules/mashlib/dist/databrowser.html",
diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json
index 1d8cf1ca2..271827531 100644
--- a/config/util/representation-conversion/default.json
+++ b/config/util/representation-conversion/default.json
@@ -11,7 +11,7 @@
       "@id": "urn:solid-server:default:RepresentationConverter",
       "@type": "WaterfallHandler",
       "handlers": [
-        { "@id": "urn:solid-server:default:IndexConverter" },
+        { "@id": "urn:solid-server:default:DefaultUiConverter" },
         {
           "@type": "IfNeededConverter",
           "comment": "Only continue converting if the requester cannot accept the available content type"
diff --git a/src/index.ts b/src/index.ts
index d41c459d3..daaf77a53 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -265,6 +265,7 @@ export * from './storage/AtomicResourceStore';
 export * from './storage/BaseResourceStore';
 export * from './storage/Conditions';
 export * from './storage/DataAccessorBasedStore';
+export * from './storage/IndexRepresentationStore';
 export * from './storage/LockingResourceStore';
 export * from './storage/MonitoringStore';
 export * from './storage/PassthroughStore';
diff --git a/src/storage/IndexRepresentationStore.ts b/src/storage/IndexRepresentationStore.ts
new file mode 100644
index 000000000..928f72e95
--- /dev/null
+++ b/src/storage/IndexRepresentationStore.ts
@@ -0,0 +1,64 @@
+import assert from 'assert';
+import type { Representation } from '../ldp/representation/Representation';
+import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
+import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
+import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
+import { isContainerIdentifier } from '../util/PathUtil';
+import type { Conditions } from './Conditions';
+import { cleanPreferences, matchesMediaType } from './conversion/ConversionUtil';
+import { PassthroughStore } from './PassthroughStore';
+import type { ResourceStore } from './ResourceStore';
+
+/**
+ * Allow containers to have a custom representation.
+ * The index representation will be returned when the following conditions are fulfilled:
+ *  * The request targets a container.
+ *  * A resource with the given `indexName` exists in the container. (default: "index.html")
+ *  * The highest weighted preference matches the `mediaRange` (default: "text/html")
+ * Otherwise the request will be passed on to the source store.
+ * In case the index representation should always be returned when it exists,
+ * the `mediaRange` should be set to "\*∕\*".
+ *
+ * Note: this functionality is not yet part of the specification. Relevant issues are:
+ * - https://github.com/solid/specification/issues/69
+ * - https://github.com/solid/specification/issues/198
+ * - https://github.com/solid/specification/issues/109
+ * - https://github.com/solid/web-access-control-spec/issues/36
+ */
+export class IndexRepresentationStore extends PassthroughStore {
+  private readonly indexName: string;
+  private readonly mediaRange: string;
+
+  public constructor(source: ResourceStore, indexName = 'index.html', mediaRange = 'text/html') {
+    super(source);
+    assert(/^[\w.-]+$/u.test(indexName), 'Invalid index name');
+    this.indexName = indexName;
+    this.mediaRange = mediaRange;
+  }
+
+  public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
+    conditions?: Conditions): Promise<Representation> {
+    if (isContainerIdentifier(identifier) && this.matchesPreferences(preferences)) {
+      try {
+        const indexIdentifier = { path: `${identifier.path}${this.indexName}` };
+        return await this.source.getRepresentation(indexIdentifier, preferences, conditions);
+      } catch (error: unknown) {
+        if (!NotFoundHttpError.isInstance(error)) {
+          throw error;
+        }
+      }
+    }
+
+    return this.source.getRepresentation(identifier, preferences, conditions);
+  }
+
+  /**
+   * Makes sure the stored media range matches the highest weight preference.
+   */
+  private matchesPreferences(preferences: RepresentationPreferences): boolean {
+    const cleaned = cleanPreferences(preferences.type);
+    const max = Math.max(...Object.values(cleaned));
+    return Object.entries(cleaned).some(([ range, weight ]): boolean =>
+      matchesMediaType(range, this.mediaRange) && weight === max);
+  }
+}


Test patch:
diff --git a/test/unit/storage/IndexRepresentationstore.test.ts b/test/unit/storage/IndexRepresentationstore.test.ts
new file mode 100644
index 000000000..9c1d223d7
--- /dev/null
+++ b/test/unit/storage/IndexRepresentationstore.test.ts
@@ -0,0 +1,75 @@
+import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
+import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
+import { IndexRepresentationStore } from '../../../src/storage/IndexRepresentationStore';
+import type { ResourceStore } from '../../../src/storage/ResourceStore';
+import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError';
+import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
+import { readableToString } from '../../../src/util/StreamUtil';
+
+describe('An IndexRepresentationStore', (): void => {
+  const baseUrl = 'http://test.com/';
+  const emptyContainer = { path: `${baseUrl}/container/` };
+  let source: jest.Mocked<ResourceStore>;
+  let store: IndexRepresentationStore;
+
+  beforeEach(async(): Promise<void> => {
+    source = {
+      getRepresentation: jest.fn((identifier: ResourceIdentifier): any => {
+        if (identifier.path === `${baseUrl}index.html`) {
+          return new BasicRepresentation('index data', 'text/html');
+        }
+        if (identifier.path.endsWith('/')) {
+          return new BasicRepresentation('container data', 'text/turtle');
+        }
+        throw new NotFoundHttpError();
+      }),
+    } as any;
+
+    store = new IndexRepresentationStore(source);
+  });
+
+  it('errors on invalid index names.', async(): Promise<void> => {
+    expect((): any => new IndexRepresentationStore(source, '../secretContainer/secret.key'))
+      .toThrow('Invalid index name');
+  });
+
+  it('retrieves the index resource if it exists.', async(): Promise<void> => {
+    const result = await store.getRepresentation({ path: baseUrl }, {});
+    await expect(readableToString(result.data)).resolves.toBe('index data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+  });
+
+  it('errors if a non-404 error was thrown when accessing the index resource.', async(): Promise<void> => {
+    source.getRepresentation.mockRejectedValueOnce(new ConflictHttpError('conflict!'));
+    await expect(store.getRepresentation({ path: baseUrl }, {})).rejects.toThrow('conflict!');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+  });
+
+  it('requests the usual data if there is no index resource.', async(): Promise<void> => {
+    const result = await store.getRepresentation(emptyContainer, {});
+    await expect(readableToString(result.data)).resolves.toBe('container data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(2);
+    expect(source.getRepresentation).toHaveBeenCalledWith({ path: `${emptyContainer.path}index.html` }, {}, undefined);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith(emptyContainer, {}, undefined);
+  });
+
+  it('requests the usual data if the index media range is not the most preferred.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }};
+    const result = await store.getRepresentation({ path: baseUrl }, preferences);
+    await expect(readableToString(result.data)).resolves.toBe('container data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: baseUrl }, preferences, undefined);
+  });
+
+  it('always returns the index resource if the media range is set to */*.', async(): Promise<void> => {
+    store = new IndexRepresentationStore(source, 'base.html', '*/*');
+    // Mocking because we also change the index name
+    source.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('index data', 'text/html'));
+
+    const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }};
+    const result = await store.getRepresentation({ path: baseUrl }, preferences);
+    await expect(readableToString(result.data)).resolves.toBe('index data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: `${baseUrl}base.html` }, preferences, undefined);
+  });
+});