mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Update templates and generators to support ACP
This commit is contained in:
parent
728617ac77
commit
40f2c8ea42
@ -14,12 +14,9 @@
|
||||
"args_path": "/",
|
||||
"args_store": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"args_generator": {
|
||||
"@type": "TemplatedResourcesGenerator",
|
||||
"@type": "StaticFolderGenerator",
|
||||
"templateFolder": "@css:templates/root/prefilled",
|
||||
"factory": { "@type": "ExtensionBasedMapperFactory" },
|
||||
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
|
||||
},
|
||||
"args_storageKey": "rootInitialized",
|
||||
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }
|
||||
|
@ -14,12 +14,9 @@
|
||||
"args_path": "/",
|
||||
"args_store": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"args_generator": {
|
||||
"@type": "TemplatedResourcesGenerator",
|
||||
"@type": "StaticFolderGenerator",
|
||||
"templateFolder": "@css:templates/root/empty",
|
||||
"factory": { "@type": "ExtensionBasedMapperFactory" },
|
||||
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
|
||||
},
|
||||
"args_storageKey": "rootInitialized",
|
||||
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }
|
||||
|
@ -60,12 +60,9 @@
|
||||
"args_path": "/",
|
||||
"args_store": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"args_generator": {
|
||||
"@type": "TemplatedResourcesGenerator",
|
||||
"@type": "StaticFolderGenerator",
|
||||
"templateFolder": "@css:templates/root/empty",
|
||||
"factory": { "@type": "ExtensionBasedMapperFactory" },
|
||||
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
|
||||
},
|
||||
"args_storageKey": "rootInitialized",
|
||||
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }
|
||||
|
@ -14,12 +14,9 @@
|
||||
"args_path": "/idp/",
|
||||
"args_store": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"args_generator": {
|
||||
"@type": "TemplatedResourcesGenerator",
|
||||
"@type": "StaticFolderGenerator",
|
||||
"templateFolder": "@css:templates/root/empty",
|
||||
"factory": { "@type": "ExtensionBasedMapperFactory" },
|
||||
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
|
||||
},
|
||||
"args_storageKey": "idpContainerInitialized",
|
||||
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }
|
||||
|
@ -14,12 +14,9 @@
|
||||
"args_path": "/.well-known/",
|
||||
"args_store": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"args_generator": {
|
||||
"@type": "TemplatedResourcesGenerator",
|
||||
"@type": "StaticFolderGenerator",
|
||||
"templateFolder": "@css:templates/root/empty",
|
||||
"factory": { "@type": "ExtensionBasedMapperFactory" },
|
||||
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
|
||||
},
|
||||
"args_storageKey": "wellKnownContainerInitialized",
|
||||
"args_storage": { "@id": "urn:solid-server:default:SetupStorage" }
|
||||
|
@ -12,7 +12,7 @@
|
||||
"@type": "ConfigPodManager",
|
||||
"podGenerator": { "@id": "urn:solid-server:default:PodGenerator" },
|
||||
"routingStorage": { "@id": "urn:solid-server:default:PodRoutingStorage" },
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:ResourcesGenerator" },
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:PodResourcesGenerator" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore" }
|
||||
},
|
||||
|
||||
|
@ -2,16 +2,31 @@
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Generates resources based on the templates stored in the template folder.",
|
||||
"@id": "urn:solid-server:default:ResourcesGenerator",
|
||||
"@type": "TemplatedResourcesGenerator",
|
||||
"comment": "Generates pods based on the templates in the corresponding folder.",
|
||||
"@id": "urn:solid-server:default:PodResourcesGenerator",
|
||||
"@type": "StaticFolderGenerator",
|
||||
"templateFolder": "@css:templates/pod",
|
||||
"factory": {
|
||||
"@type": "ExtensionBasedMapperFactory"
|
||||
},
|
||||
"templateEngine": { "@id": "urn:solid-server:default:TemplateEngine" },
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" }
|
||||
},
|
||||
{
|
||||
"comment": [
|
||||
"Generates resources in the base subfolder of the provided folder.",
|
||||
"Authorization configurations should add their corresponding subfolder containing the relevant authorization files."
|
||||
],
|
||||
"@id": "urn:solid-server:default:TemplatedResourcesGenerator",
|
||||
"@type": "SubfolderResourcesGenerator",
|
||||
"subfolders": [ "base" ],
|
||||
"resourcesGenerator": {
|
||||
"@type": "BaseResourcesGenerator",
|
||||
"factory": {
|
||||
"@type": "ExtensionBasedMapperFactory"
|
||||
},
|
||||
"templateEngine": {
|
||||
"@id": "urn:solid-server:default:TemplateEngine"
|
||||
},
|
||||
"metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" },
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
"@id": "urn:solid-server:default:PodManager",
|
||||
"@type": "GeneratedPodManager",
|
||||
"store": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:ResourcesGenerator" }
|
||||
"resourcesGenerator": { "@id": "urn:solid-server:default:PodResourcesGenerator" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -31,6 +31,12 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "The templates for ACP authorization documents are in the acp subfolder.",
|
||||
"@id": "urn:solid-server:default:TemplatedResourcesGenerator",
|
||||
"@type": "SubfolderResourcesGenerator",
|
||||
"subfolders": [ "acp" ]
|
||||
},
|
||||
{
|
||||
"comment": "In case of ACP authorization the ACR resources determine authorization.",
|
||||
"@id": "urn:solid-server:default:AuthResourceHttpHandler",
|
||||
|
@ -31,6 +31,12 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "The templates for WAC authorization documents are in the wac subfolder.",
|
||||
"@id": "urn:solid-server:default:TemplatedResourcesGenerator",
|
||||
"@type": "SubfolderResourcesGenerator",
|
||||
"subfolders": [ "wac" ]
|
||||
},
|
||||
{
|
||||
"comment": "In case of WebACL authorization the ACL resources determine authorization.",
|
||||
"@id": "urn:solid-server:default:AuthResourceHttpHandler",
|
||||
|
@ -258,12 +258,15 @@ export * from './pods/generate/variables/VariableSetter';
|
||||
|
||||
// Pods/Generate
|
||||
export * from './pods/generate/BaseComponentsJsFactory';
|
||||
export * from './pods/generate/BaseResourcesGenerator';
|
||||
export * from './pods/generate/ComponentsJsFactory';
|
||||
export * from './pods/generate/GenerateUtil';
|
||||
export * from './pods/generate/IdentifierGenerator';
|
||||
export * from './pods/generate/PodGenerator';
|
||||
export * from './pods/generate/ResourcesGenerator';
|
||||
export * from './pods/generate/StaticFolderGenerator';
|
||||
export * from './pods/generate/SubdomainIdentifierGenerator';
|
||||
export * from './pods/generate/SubfolderResourcesGenerator';
|
||||
export * from './pods/generate/SuffixIdentifierGenerator';
|
||||
export * from './pods/generate/TemplatedPodGenerator';
|
||||
export * from './pods/generate/TemplatedResourcesGenerator';
|
||||
|
237
src/pods/generate/BaseResourcesGenerator.ts
Normal file
237
src/pods/generate/BaseResourcesGenerator.ts
Normal file
@ -0,0 +1,237 @@
|
||||
import { createReadStream, promises as fsPromises } from 'fs';
|
||||
import type { Readable } from 'stream';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import { Parser } from 'n3';
|
||||
import type { AuxiliaryStrategy } from '../../http/auxiliary/AuxiliaryStrategy';
|
||||
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
||||
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../../logging/LogUtil';
|
||||
import type {
|
||||
FileIdentifierMapper,
|
||||
FileIdentifierMapperFactory,
|
||||
ResourceLink,
|
||||
} from '../../storage/mapping/FileIdentifierMapper';
|
||||
import type { ResourceSet } from '../../storage/ResourceSet';
|
||||
import { INTERNAL_QUADS } from '../../util/ContentTypes';
|
||||
import { guardStream } from '../../util/GuardedStream';
|
||||
import type { Guarded } from '../../util/GuardedStream';
|
||||
import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
|
||||
import { addResourceMetadata } from '../../util/ResourceUtil';
|
||||
import { guardedStreamFrom, readableToString } from '../../util/StreamUtil';
|
||||
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
|
||||
import type { Resource } from './ResourcesGenerator';
|
||||
import type { TemplatedResourcesGenerator } from './TemplatedResourcesGenerator';
|
||||
import Dict = NodeJS.Dict;
|
||||
|
||||
interface TemplateResourceLink extends ResourceLink {
|
||||
isTemplate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input arguments required for {@link BaseResourcesGenerator}
|
||||
*/
|
||||
export interface SubfolderResourcesGeneratorArgs {
|
||||
/**
|
||||
* Factory used to generate mapper relative to the base identifier.
|
||||
*/
|
||||
factory: FileIdentifierMapperFactory;
|
||||
/**
|
||||
* Template engine for generating the resources.
|
||||
*/
|
||||
templateEngine: TemplateEngine;
|
||||
/**
|
||||
* The extension of files that need to be interpreted as templates.
|
||||
* Will be removed to generate the identifier.
|
||||
*/
|
||||
templateExtension?: string;
|
||||
/**
|
||||
* The metadataStrategy
|
||||
*/
|
||||
metadataStrategy: AuxiliaryStrategy;
|
||||
/**
|
||||
* The default ResourceStore
|
||||
*/
|
||||
store: ResourceSet;
|
||||
}
|
||||
|
||||
// Comparator for the results of the `groupLinks` call
|
||||
function comparator(left: { link: TemplateResourceLink }, right: { link: TemplateResourceLink }): number {
|
||||
return left.link.identifier.path.localeCompare(right.link.identifier.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates resources by making use of a template engine.
|
||||
* The template folder structure will be kept.
|
||||
* Folders will be interpreted as containers and files as documents.
|
||||
* A FileIdentifierMapper will be used to generate identifiers that correspond to the relative structure.
|
||||
*
|
||||
* Metadata resources will be yielded separately from their subject resource.
|
||||
*
|
||||
* A relative `templateFolder` is resolved relative to cwd,
|
||||
* unless it's preceded by `@css:`, e.g. `@css:foo/bar`.
|
||||
*/
|
||||
export class BaseResourcesGenerator implements TemplatedResourcesGenerator {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly factory: FileIdentifierMapperFactory;
|
||||
private readonly templateEngine: TemplateEngine;
|
||||
private readonly templateExtension: string;
|
||||
private readonly metadataStrategy: AuxiliaryStrategy;
|
||||
private readonly store: ResourceSet;
|
||||
|
||||
/**
|
||||
* A mapper is needed to convert the template file paths to identifiers relative to the given base identifier.
|
||||
*
|
||||
* @param args - TemplatedResourcesGeneratorArgs
|
||||
*/
|
||||
public constructor(args: SubfolderResourcesGeneratorArgs) {
|
||||
this.factory = args.factory;
|
||||
this.templateEngine = args.templateEngine;
|
||||
this.templateExtension = args.templateExtension ?? '.hbs';
|
||||
this.metadataStrategy = args.metadataStrategy;
|
||||
this.store = args.store;
|
||||
}
|
||||
|
||||
public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict<string>):
|
||||
AsyncIterable<Resource> {
|
||||
templateFolder = resolveAssetPath(templateFolder);
|
||||
|
||||
// Ignore folders that don't exist
|
||||
if (!await pathExists(templateFolder)) {
|
||||
this.logger.warn(`Ignoring non-existing template folder ${templateFolder}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mapper = await this.factory.create(location.path, templateFolder);
|
||||
const folderLink = await this.toTemplateLink(templateFolder, mapper);
|
||||
yield* this.processFolder(folderLink, mapper, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates results for all entries in the given folder, including the folder itself.
|
||||
*/
|
||||
private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict<string>):
|
||||
AsyncIterable<Resource> {
|
||||
// Group resource links with their corresponding metadata links
|
||||
const links = await this.groupLinks(folderLink.filePath, mapper);
|
||||
|
||||
// Remove root metadata if it exists
|
||||
const metaLink = links[folderLink.identifier.path]?.meta;
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete links[folderLink.identifier.path];
|
||||
|
||||
yield* this.generateResource(folderLink, options, metaLink);
|
||||
|
||||
// Make sure the results are sorted
|
||||
for (const { link, meta } of Object.values(links).sort(comparator)) {
|
||||
if (isContainerIdentifier(link.identifier)) {
|
||||
yield* this.processFolder(link, mapper, options);
|
||||
} else {
|
||||
yield* this.generateResource(link, options, meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TemplateResourceLink for the given filePath,
|
||||
* which connects a resource URL to its template file.
|
||||
* The identifier will be based on the file path stripped from the template extension,
|
||||
* but the filePath parameter will still point to the original file.
|
||||
*/
|
||||
private async toTemplateLink(filePath: string, mapper: FileIdentifierMapper): Promise<TemplateResourceLink> {
|
||||
const stats = await fsPromises.lstat(filePath);
|
||||
|
||||
// Slice the template extension from the filepath for correct identifier generation
|
||||
const isTemplate = filePath.endsWith(this.templateExtension);
|
||||
const slicedPath = isTemplate ? filePath.slice(0, -this.templateExtension.length) : filePath;
|
||||
const link = await mapper.mapFilePathToUrl(slicedPath, stats.isDirectory());
|
||||
// We still need the original file path for disk reading though
|
||||
return {
|
||||
...link,
|
||||
filePath,
|
||||
isTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates TemplateResourceLinks for each entry in the given folder
|
||||
* and combines the results so resources and their metadata are grouped together.
|
||||
*/
|
||||
private async groupLinks(folderPath: string, mapper: FileIdentifierMapper):
|
||||
Promise<Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }>> {
|
||||
const files = await fsPromises.readdir(folderPath);
|
||||
const links: Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }> = { };
|
||||
for (const name of files) {
|
||||
const link = await this.toTemplateLink(joinFilePath(folderPath, name), mapper);
|
||||
const { path } = link.identifier;
|
||||
links[path] = Object.assign(links[path] || {}, link.isMetadata ? { meta: link } : { link });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Resource object for the given ResourceLink.
|
||||
* In the case of documents the corresponding template will be used.
|
||||
* If a ResourceLink of metadata is provided the corresponding metadata resource
|
||||
* will be yielded as a separate resource.
|
||||
*/
|
||||
private async* generateResource(link: TemplateResourceLink, options: Dict<string>, metaLink?: TemplateResourceLink):
|
||||
AsyncIterable<Resource> {
|
||||
let data: Guarded<Readable> | undefined;
|
||||
const metadata = new RepresentationMetadata(link.identifier);
|
||||
|
||||
// Read file if it is not a container
|
||||
if (!isContainerIdentifier(link.identifier)) {
|
||||
data = await this.processFile(link, options);
|
||||
metadata.contentType = link.contentType;
|
||||
}
|
||||
// Do not yield a container resource if it already exists
|
||||
if (!isContainerIdentifier(link.identifier) || !await this.store.hasResource(link.identifier)) {
|
||||
this.logger.debug(`Generating resource ${link.identifier.path}`);
|
||||
yield {
|
||||
identifier: link.identifier,
|
||||
representation: new BasicRepresentation(data ?? [], metadata),
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata from .meta file if there is one
|
||||
if (metaLink) {
|
||||
const rawMetadata = await this.generateMetadata(metaLink, options);
|
||||
const metaIdentifier = this.metadataStrategy.getAuxiliaryIdentifier(link.identifier);
|
||||
const descriptionMeta = new RepresentationMetadata(metaIdentifier);
|
||||
addResourceMetadata(rawMetadata, isContainerIdentifier(link.identifier));
|
||||
this.logger.debug(`Generating resource ${metaIdentifier.path}`);
|
||||
yield {
|
||||
identifier: metaIdentifier,
|
||||
representation: new BasicRepresentation(rawMetadata.quads(), descriptionMeta, INTERNAL_QUADS),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a RepresentationMetadata using the given template.
|
||||
*/
|
||||
private async generateMetadata(metaLink: TemplateResourceLink, options: Dict<string>):
|
||||
Promise<RepresentationMetadata> {
|
||||
const metadata = new RepresentationMetadata(metaLink.identifier);
|
||||
|
||||
const data = await this.processFile(metaLink, options);
|
||||
const parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path });
|
||||
const quads = parser.parse(await readableToString(data));
|
||||
metadata.addQuads(quads);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a read stream from the file and applies the template if necessary.
|
||||
*/
|
||||
private async processFile(link: TemplateResourceLink, contents: Dict<string>): Promise<Guarded<Readable>> {
|
||||
if (link.isTemplate) {
|
||||
const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: link.filePath }});
|
||||
return guardedStreamFrom(rendered);
|
||||
}
|
||||
return guardStream(createReadStream(link.filePath));
|
||||
}
|
||||
}
|
@ -14,7 +14,7 @@ export interface Resource {
|
||||
export interface ResourcesGenerator {
|
||||
/**
|
||||
* Generates resources with the given options.
|
||||
* The output Map should be sorted so that containers always appear before their contents.
|
||||
* The output Iterable should be sorted so that containers always appear before their contents.
|
||||
* @param location - Base identifier.
|
||||
* @param options - Options that can be used when generating resources.
|
||||
*
|
||||
|
21
src/pods/generate/StaticFolderGenerator.ts
Normal file
21
src/pods/generate/StaticFolderGenerator.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import type { Resource, ResourcesGenerator } from './ResourcesGenerator';
|
||||
import type { TemplatedResourcesGenerator } from './TemplatedResourcesGenerator';
|
||||
import Dict = NodeJS.Dict;
|
||||
|
||||
/**
|
||||
* Stores a static template folder that will be used to call the wrapped {@link TemplatedResourcesGenerator}.
|
||||
*/
|
||||
export class StaticFolderGenerator implements ResourcesGenerator {
|
||||
private readonly resourcesGenerator: TemplatedResourcesGenerator;
|
||||
private readonly templateFolder: string;
|
||||
|
||||
public constructor(resourcesGenerator: TemplatedResourcesGenerator, templateFolder: string) {
|
||||
this.resourcesGenerator = resourcesGenerator;
|
||||
this.templateFolder = templateFolder;
|
||||
}
|
||||
|
||||
public generate(location: ResourceIdentifier, options: Dict<string>): AsyncIterable<Resource> {
|
||||
return this.resourcesGenerator.generate(this.templateFolder, location, options);
|
||||
}
|
||||
}
|
56
src/pods/generate/SubfolderResourcesGenerator.ts
Normal file
56
src/pods/generate/SubfolderResourcesGenerator.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { sortedAsyncMerge } from '../../util/IterableUtil';
|
||||
import { joinFilePath, resolveAssetPath } from '../../util/PathUtil';
|
||||
import type { Resource } from './ResourcesGenerator';
|
||||
import type { TemplatedResourcesGenerator } from './TemplatedResourcesGenerator';
|
||||
import Dict = NodeJS.Dict;
|
||||
|
||||
// Sorts Resources based on their identifiers
|
||||
function comparator(left: Resource, right: Resource): number {
|
||||
return left.identifier.path.localeCompare(right.identifier.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates all resources found in specific subfolders of the given template folder.
|
||||
* In case the same resource is defined in several subfolders,
|
||||
* the data of the last subfolder in the list will be used.
|
||||
*
|
||||
* The results of all the subfolders will be merged so the end result is still a sorted stream of identifiers.
|
||||
*
|
||||
* One of the main use cases for this class is so template resources can be in a separate folder
|
||||
* than their corresponding authorization resources,
|
||||
* allowing for authorization-independent templates.
|
||||
*/
|
||||
export class SubfolderResourcesGenerator implements TemplatedResourcesGenerator {
|
||||
private readonly resourcesGenerator: TemplatedResourcesGenerator;
|
||||
private readonly subfolders: string[];
|
||||
|
||||
public constructor(resourcesGenerator: TemplatedResourcesGenerator, subfolders: string[]) {
|
||||
this.resourcesGenerator = resourcesGenerator;
|
||||
this.subfolders = subfolders;
|
||||
}
|
||||
|
||||
public async* generate(templateFolder: string, location: ResourceIdentifier, options: Dict<string>):
|
||||
AsyncIterable<Resource> {
|
||||
const root = resolveAssetPath(templateFolder);
|
||||
const templateSubfolders = this.subfolders.map((subfolder): string => joinFilePath(root, subfolder));
|
||||
|
||||
// Build all generators
|
||||
const generators: AsyncIterator<Resource>[] = [];
|
||||
for (const templateSubfolder of templateSubfolders) {
|
||||
generators.push(this.resourcesGenerator.generate(templateSubfolder, location, options)[Symbol.asyncIterator]());
|
||||
}
|
||||
|
||||
let previous: ResourceIdentifier = { path: '' };
|
||||
for await (const result of sortedAsyncMerge(generators, comparator)) {
|
||||
// Skip duplicate results.
|
||||
// In practice these are just going to be the same empty containers.
|
||||
if (result.identifier.path === previous.path) {
|
||||
result.representation.data.destroy();
|
||||
} else {
|
||||
previous = result.identifier;
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,216 +1,21 @@
|
||||
import { createReadStream, promises as fsPromises } from 'fs';
|
||||
import type { Readable } from 'stream';
|
||||
import { Parser } from 'n3';
|
||||
import type { AuxiliaryStrategy } from '../../http/auxiliary/AuxiliaryStrategy';
|
||||
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
||||
import { RepresentationMetadata } from '../../http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import type {
|
||||
FileIdentifierMapper,
|
||||
FileIdentifierMapperFactory,
|
||||
ResourceLink,
|
||||
} from '../../storage/mapping/FileIdentifierMapper';
|
||||
import type { ResourceSet } from '../../storage/ResourceSet';
|
||||
import { INTERNAL_QUADS } from '../../util/ContentTypes';
|
||||
import { guardStream } from '../../util/GuardedStream';
|
||||
import type { Guarded } from '../../util/GuardedStream';
|
||||
import { joinFilePath, isContainerIdentifier, resolveAssetPath } from '../../util/PathUtil';
|
||||
import { addResourceMetadata } from '../../util/ResourceUtil';
|
||||
import { guardedStreamFrom, readableToString } from '../../util/StreamUtil';
|
||||
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
|
||||
import type { Resource, ResourcesGenerator } from './ResourcesGenerator';
|
||||
import type { Resource } from './ResourcesGenerator';
|
||||
import Dict = NodeJS.Dict;
|
||||
|
||||
interface TemplateResourceLink extends ResourceLink {
|
||||
isTemplate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input arguments required for {@link TemplatedResourcesGenerator}
|
||||
* Generator used to create resources relative to a given base identifier.
|
||||
* Similar to {@link ResourcesGenerator}, but takes as input a string
|
||||
* indicating where the templates are stored that need to be used for resource generation.
|
||||
*/
|
||||
export interface TemplatedResourcesGeneratorArgs {
|
||||
export interface TemplatedResourcesGenerator {
|
||||
/**
|
||||
* Folder where the templates are located.
|
||||
*/
|
||||
templateFolder: string;
|
||||
/**
|
||||
* Factory used to generate mapper relative to the base identifier.
|
||||
*/
|
||||
factory: FileIdentifierMapperFactory;
|
||||
/**
|
||||
* Template engine for generating the resources.
|
||||
*/
|
||||
templateEngine: TemplateEngine;
|
||||
/**
|
||||
* The extension of files that need to be interpreted as templates.
|
||||
* Will be removed to generate the identifier.
|
||||
*/
|
||||
templateExtension?: string;
|
||||
/**
|
||||
* The metadataStrategy
|
||||
*/
|
||||
metadataStrategy: AuxiliaryStrategy;
|
||||
/**
|
||||
* The default ResourceStore
|
||||
*/
|
||||
store: ResourceSet;
|
||||
}
|
||||
/**
|
||||
* Generates resources by making use of a template engine.
|
||||
* The template folder structure will be kept.
|
||||
* Folders will be interpreted as containers and files as documents.
|
||||
* A FileIdentifierMapper will be used to generate identifiers that correspond to the relative structure.
|
||||
*
|
||||
* A relative `templateFolder` is resolved relative to cwd,
|
||||
* unless it's preceded by `@css:`, e.g. `@css:foo/bar`.
|
||||
*/
|
||||
export class TemplatedResourcesGenerator implements ResourcesGenerator {
|
||||
private readonly templateFolder: string;
|
||||
private readonly factory: FileIdentifierMapperFactory;
|
||||
private readonly templateEngine: TemplateEngine;
|
||||
private readonly templateExtension: string;
|
||||
private readonly metadataStrategy: AuxiliaryStrategy;
|
||||
private readonly store: ResourceSet;
|
||||
|
||||
/**
|
||||
* A mapper is needed to convert the template file paths to identifiers relative to the given base identifier.
|
||||
* Generates resources with the given options, based on the given template folder.
|
||||
* The output Iterable should be sorted so that containers always appear before their contents.
|
||||
* @param templateFolder - Folder where the templates are located.
|
||||
* @param location - Base identifier.
|
||||
* @param options - Options that can be used when generating resources.
|
||||
*
|
||||
* @param args - TemplatedResourcesGeneratorArgs
|
||||
* @returns A map where the keys are the identifiers and the values the corresponding representations to store.
|
||||
*/
|
||||
public constructor(args: TemplatedResourcesGeneratorArgs) {
|
||||
this.templateFolder = resolveAssetPath(args.templateFolder);
|
||||
this.factory = args.factory;
|
||||
this.templateEngine = args.templateEngine;
|
||||
this.templateExtension = args.templateExtension ?? '.hbs';
|
||||
this.metadataStrategy = args.metadataStrategy;
|
||||
this.store = args.store;
|
||||
}
|
||||
|
||||
public async* generate(location: ResourceIdentifier, options: Dict<string>): AsyncIterable<Resource> {
|
||||
const mapper = await this.factory.create(location.path, this.templateFolder);
|
||||
const folderLink = await this.toTemplateLink(this.templateFolder, mapper);
|
||||
yield* this.processFolder(folderLink, mapper, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates results for all entries in the given folder, including the folder itself.
|
||||
*/
|
||||
private async* processFolder(folderLink: TemplateResourceLink, mapper: FileIdentifierMapper, options: Dict<string>):
|
||||
AsyncIterable<Resource> {
|
||||
// Group resource links with their corresponding metadata links
|
||||
const links = await this.groupLinks(folderLink.filePath, mapper);
|
||||
|
||||
// Remove root metadata if it exists
|
||||
const metaLink = links[folderLink.identifier.path]?.meta;
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete links[folderLink.identifier.path];
|
||||
|
||||
yield* this.generateResource(folderLink, options, metaLink);
|
||||
|
||||
for (const { link, meta } of Object.values(links)) {
|
||||
if (isContainerIdentifier(link.identifier)) {
|
||||
yield* this.processFolder(link, mapper, options);
|
||||
} else {
|
||||
yield* this.generateResource(link, options, meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TemplateResourceLink for the given filePath.
|
||||
* The identifier will be based on the file path stripped from the template extension,
|
||||
* but the filePath parameter will still point to the original file.
|
||||
*/
|
||||
private async toTemplateLink(filePath: string, mapper: FileIdentifierMapper): Promise<TemplateResourceLink> {
|
||||
const stats = await fsPromises.lstat(filePath);
|
||||
|
||||
// Slice the template extension from the filepath for correct identifier generation
|
||||
const isTemplate = filePath.endsWith(this.templateExtension);
|
||||
const slicedPath = isTemplate ? filePath.slice(0, -this.templateExtension.length) : filePath;
|
||||
const link = await mapper.mapFilePathToUrl(slicedPath, stats.isDirectory());
|
||||
// We still need the original file path for disk reading though
|
||||
return {
|
||||
...link,
|
||||
filePath,
|
||||
isTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates TemplateResourceLinks for each entry in the given folder
|
||||
* and combines the results so resources and their metadata are grouped together.
|
||||
*/
|
||||
private async groupLinks(folderPath: string, mapper: FileIdentifierMapper):
|
||||
Promise<Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }>> {
|
||||
const files = await fsPromises.readdir(folderPath);
|
||||
const links: Record<string, { link: TemplateResourceLink; meta?: TemplateResourceLink }> = { };
|
||||
for (const name of files) {
|
||||
const link = await this.toTemplateLink(joinFilePath(folderPath, name), mapper);
|
||||
const { path } = link.identifier;
|
||||
links[path] = Object.assign(links[path] || {}, link.isMetadata ? { meta: link } : { link });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Resource object for the given ResourceLink.
|
||||
* In the case of documents the corresponding template will be used.
|
||||
* If a ResourceLink of metadata is provided the corresponding data will be added as metadata.
|
||||
*/
|
||||
private async* generateResource(link: TemplateResourceLink, options: Dict<string>, metaLink?: TemplateResourceLink):
|
||||
AsyncIterable<Resource> {
|
||||
let data: Guarded<Readable> | undefined;
|
||||
const metadata = new RepresentationMetadata(link.identifier);
|
||||
|
||||
// Read file if it is not a container
|
||||
if (!isContainerIdentifier(link.identifier)) {
|
||||
data = await this.processFile(link, options);
|
||||
metadata.contentType = link.contentType;
|
||||
}
|
||||
// Do not yield a container resource if it already exists
|
||||
if (!isContainerIdentifier(link.identifier) || !await this.store.hasResource(link.identifier)) {
|
||||
yield {
|
||||
identifier: link.identifier,
|
||||
representation: new BasicRepresentation(data ?? [], metadata),
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata from .meta file if there is one
|
||||
if (metaLink) {
|
||||
const rawMetadata = await this.generateMetadata(metaLink, options);
|
||||
const metaIdentifier = this.metadataStrategy.getAuxiliaryIdentifier(link.identifier);
|
||||
const descriptionMeta = new RepresentationMetadata(metaIdentifier);
|
||||
addResourceMetadata(rawMetadata, isContainerIdentifier(link.identifier));
|
||||
yield {
|
||||
identifier: metaIdentifier,
|
||||
representation: new BasicRepresentation(rawMetadata.quads(), descriptionMeta, INTERNAL_QUADS),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a RepresentationMetadata using the given template.
|
||||
*/
|
||||
private async generateMetadata(metaLink: TemplateResourceLink, options: Dict<string>):
|
||||
Promise<RepresentationMetadata> {
|
||||
const metadata = new RepresentationMetadata(metaLink.identifier);
|
||||
|
||||
const data = await this.processFile(metaLink, options);
|
||||
const parser = new Parser({ format: metaLink.contentType, baseIRI: metaLink.identifier.path });
|
||||
const quads = parser.parse(await readableToString(data));
|
||||
metadata.addQuads(quads);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a read stream from the file and applies the template if necessary.
|
||||
*/
|
||||
private async processFile(link: TemplateResourceLink, contents: Dict<string>): Promise<Guarded<Readable>> {
|
||||
if (link.isTemplate) {
|
||||
const rendered = await this.templateEngine.handleSafe({ contents, template: { templateFile: link.filePath }});
|
||||
return guardedStreamFrom(rendered);
|
||||
}
|
||||
return guardStream(createReadStream(link.filePath));
|
||||
}
|
||||
generate: (templateFolder: string, location: ResourceIdentifier, options: Dict<string>) => AsyncIterable<Resource>;
|
||||
}
|
||||
|
@ -15,5 +15,6 @@ export const INTERNAL_ERROR = 'internal/error';
|
||||
|
||||
export const DEFAULT_CUSTOM_TYPES = {
|
||||
acl: TEXT_TURTLE,
|
||||
acr: TEXT_TURTLE,
|
||||
meta: TEXT_TURTLE,
|
||||
};
|
||||
|
@ -113,3 +113,93 @@ export function reduce<TIn, TOut>(iterable: Iterable<TIn>,
|
||||
}
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for {@link sortedAsyncMerge}.
|
||||
*
|
||||
* Returns the next result of an AsyncIterator, or undefined if the iterator is finished.
|
||||
*/
|
||||
async function nextAsyncEntry<T>(iterator: AsyncIterator<T>): Promise<T | undefined> {
|
||||
const result = await iterator.next();
|
||||
if (result.done) {
|
||||
return;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for {@link sortedAsyncMerge}.
|
||||
*
|
||||
* Compares the next results of all `iterators` and returns the first one,
|
||||
* determined by the provided `comparator`.
|
||||
*
|
||||
* `results` should contain the first result of all these iterators.
|
||||
* This array will also be updated, replacing the result of the iterator whose result was chosen by the next one.
|
||||
*/
|
||||
async function findNextSorted<T>(iterators: AsyncIterator<T>[], results: (T | undefined)[],
|
||||
comparator: (left: T, right: T) => number): Promise<T | undefined> {
|
||||
let best: { idx: number; value: T } | undefined;
|
||||
// For every iterator: see if their next result is the best one so far
|
||||
for (let i = 0; i < iterators.length; ++i) {
|
||||
const value = results[i];
|
||||
if (typeof value !== 'undefined') {
|
||||
let compare = 1;
|
||||
if (best) {
|
||||
compare = comparator(best.value, value);
|
||||
}
|
||||
|
||||
if (compare > 0) {
|
||||
best = { idx: i, value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
// Advance the iterator that returned the new result
|
||||
results[best.idx] = await nextAsyncEntry(iterators[best.idx]);
|
||||
}
|
||||
|
||||
// Will return undefined if `best` was never initialized above
|
||||
return best?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the results of several sorted iterators.
|
||||
* In case the results of the individual iterators are not sorted the outcome results will also not be sorted.
|
||||
*
|
||||
* @param iterators - The iterators whose results need to be merged.
|
||||
* @param comparator - The comparator to use to compare the results.
|
||||
*/
|
||||
export async function* sortedAsyncMerge<T>(iterators: AsyncIterator<T>[], comparator?: (left: T, right: T) => number):
|
||||
AsyncIterable<T> {
|
||||
if (!comparator) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-extra-parens
|
||||
comparator = (left, right): number => left < right ? -1 : (left > right ? 1 : 0);
|
||||
}
|
||||
|
||||
// Initialize the array to the first result of every iterator
|
||||
const results: (T | undefined)[] = [];
|
||||
for (const iterator of iterators) {
|
||||
results.push(await nextAsyncEntry(iterator));
|
||||
}
|
||||
|
||||
// Keep returning results as long as we find them
|
||||
while (true) {
|
||||
const next = await findNextSorted(iterators, results, comparator);
|
||||
if (typeof next === 'undefined') {
|
||||
return;
|
||||
}
|
||||
yield next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an `AsyncIterator` to an array.
|
||||
*/
|
||||
export async function asyncToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
const arr: T[] = [];
|
||||
for await (const result of iterable) {
|
||||
arr.push(result);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
39
templates/pod/acp/.acr.hbs
Normal file
39
templates/pod/acp/.acr.hbs
Normal file
@ -0,0 +1,39 @@
|
||||
# Root ACR for the agent account
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
|
||||
|
||||
# The owner has full access to every resource in their pod.
|
||||
# Other agents have no access rights,
|
||||
# unless specifically authorized in other ACRs.
|
||||
<#root>
|
||||
a acp:AccessControlResource;
|
||||
# Set the access to the root storage folder itself
|
||||
acp:resource <./>;
|
||||
# The homepage is readable by the public
|
||||
acp:accessControl <#fullOwnerAccess>, <#publicReadAccess>;
|
||||
# All resources will inherit this authorization
|
||||
acp:memberAccessControl <#fullOwnerAccess>.
|
||||
|
||||
# The public only has read access
|
||||
<#publicReadAccess>
|
||||
a acp:AccessControl;
|
||||
acp:apply [
|
||||
a acp:Policy;
|
||||
acp:allow acl:Read;
|
||||
acp:anyOf [
|
||||
a acp:Matcher;
|
||||
acp:agent acp:PublicAgent
|
||||
]
|
||||
].
|
||||
|
||||
# The owner has all of the access modes allowed
|
||||
<#fullOwnerAccess>
|
||||
a acp:AccessControl;
|
||||
acp:apply [
|
||||
a acp:Policy;
|
||||
acp:allow acl:Read, acl:Write, acl:Control;
|
||||
acp:anyOf [
|
||||
a acp:Matcher;
|
||||
acp:agent <{{webId}}>
|
||||
]
|
||||
].
|
18
templates/pod/acp/README.acr
Normal file
18
templates/pod/acp/README.acr
Normal file
@ -0,0 +1,18 @@
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
|
||||
|
||||
<#card>
|
||||
a acp:AccessControlResource;
|
||||
acp:resource <./README>;
|
||||
acp:accessControl <#publicReadAccess>.
|
||||
|
||||
<#publicReadAccess>
|
||||
a acp:AccessControl;
|
||||
acp:apply [
|
||||
a acp:Policy;
|
||||
acp:allow acl:Read;
|
||||
acp:anyOf [
|
||||
a acp:Matcher;
|
||||
acp:agent acp:PublicAgent
|
||||
]
|
||||
].
|
22
templates/pod/acp/profile/card.acr
Normal file
22
templates/pod/acp/profile/card.acr
Normal file
@ -0,0 +1,22 @@
|
||||
# ACR for the WebID profile document
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
|
||||
|
||||
# The WebID profile is readable by the public.
|
||||
# This is required for discovery and verification,
|
||||
# e.g. when checking identity providers.
|
||||
<#card>
|
||||
a acp:AccessControlResource;
|
||||
acp:resource <./card>;
|
||||
acp:accessControl <#publicReadAccess>.
|
||||
|
||||
<#publicReadAccess>
|
||||
a acp:AccessControl;
|
||||
acp:apply [
|
||||
a acp:Policy;
|
||||
acp:allow acl:Read;
|
||||
acp:anyOf [
|
||||
a acp:Matcher;
|
||||
acp:agent acp:PublicAgent
|
||||
]
|
||||
].
|
32
templates/root/empty/acp/.acr
Normal file
32
templates/root/empty/acp/.acr
Normal file
@ -0,0 +1,32 @@
|
||||
# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES.
|
||||
# WHEN IN DOUBT, DELETE THIS DOCUMENT.
|
||||
#
|
||||
# This root ACR allows unrestricted public access to all documents and subcontainers.
|
||||
#
|
||||
# This document was automatically generated by the Community Solid Server
|
||||
# because the "Expose a public root Pod" option was selected during setup,
|
||||
# or because setup has been bypassed.
|
||||
#
|
||||
# We strongly suggest to edit this document such that it restricts permissions.
|
||||
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
|
||||
|
||||
# Give all agents Read, Write, and Control permissions on everything
|
||||
<#card>
|
||||
a acp:AccessControlResource;
|
||||
acp:resource <./>;
|
||||
acp:accessControl <#publicReadAccess>;
|
||||
acp:memberAccessControl <#publicReadAccess> .
|
||||
|
||||
<#publicReadAccess>
|
||||
a acp:AccessControl;
|
||||
acp:apply [
|
||||
a acp:Policy;
|
||||
acp:allow acl:Read, acl:Write, acl:Control;
|
||||
acp:anyOf [
|
||||
a acp:Matcher;
|
||||
acp:agent acp:PublicAgent
|
||||
]
|
||||
].
|
||||
|
32
templates/root/prefilled/acp/.acr
Normal file
32
templates/root/prefilled/acp/.acr
Normal file
@ -0,0 +1,32 @@
|
||||
# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES.
|
||||
# WHEN IN DOUBT, DELETE THIS DOCUMENT.
|
||||
#
|
||||
# This root ACR allows unrestricted public access to all documents and subcontainers.
|
||||
#
|
||||
# This document was automatically generated by the Community Solid Server
|
||||
# because the "Expose a public root Pod" option was selected during setup,
|
||||
# or because setup has been bypassed.
|
||||
#
|
||||
# We strongly suggest to edit this document such that it restricts permissions.
|
||||
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
|
||||
|
||||
# Give all agents Read, Write, and Control permissions on everything
|
||||
<#card>
|
||||
a acp:AccessControlResource;
|
||||
acp:resource <./>;
|
||||
acp:accessControl <#publicReadAccess>;
|
||||
acp:memberAccessControl <#publicReadAccess> .
|
||||
|
||||
<#publicReadAccess>
|
||||
a acp:AccessControl;
|
||||
acp:apply [
|
||||
a acp:Policy;
|
||||
acp:allow acl:Read, acl:Write, acl:Control;
|
||||
acp:anyOf [
|
||||
a acp:Matcher;
|
||||
acp:agent acp:PublicAgent
|
||||
]
|
||||
].
|
||||
|
@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="./"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
|
||||
<a href=".."><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
|
||||
<h1>Community Solid Server</h1>
|
||||
</header>
|
||||
<main>
|
@ -1,5 +1,5 @@
|
||||
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
||||
|
||||
<{{webId}}>
|
||||
a foaf:Person ;
|
||||
a foaf:Person;
|
||||
foaf:name "{{name}}".
|
||||
|
@ -63,7 +63,7 @@ describe('An AcpReader', (): void => {
|
||||
const target = { path: joinUrl(baseUrl, 'foo') };
|
||||
dataMap[baseUrl] = toQuads(`
|
||||
[]
|
||||
acp:resource <./> ;
|
||||
acp:resource <./>;
|
||||
acp:accessControl [ acp:apply _:policy ].
|
||||
_:policy
|
||||
acp:allow acl:Read;
|
||||
@ -89,7 +89,7 @@ describe('An AcpReader', (): void => {
|
||||
const target = { path: joinUrl(baseUrl, 'foo') };
|
||||
dataMap[baseUrl] = toQuads(`
|
||||
[]
|
||||
acp:resource <./> ;
|
||||
acp:resource <./>;
|
||||
acp:memberAccessControl [ acp:apply _:policy ].
|
||||
_:policy
|
||||
acp:allow acl:Read;
|
||||
@ -109,7 +109,7 @@ describe('An AcpReader', (): void => {
|
||||
const target = { path: joinUrl(baseUrl, 'foo') };
|
||||
dataMap[baseUrl] = toQuads(`
|
||||
[]
|
||||
acp:resource <./> ;
|
||||
acp:resource <./>;
|
||||
acp:accessControl [ acp:apply _:controlPolicy ];
|
||||
acp:memberAccessControl [ acp:apply _:readPolicy ].
|
||||
_:readPolicy
|
||||
@ -122,7 +122,7 @@ describe('An AcpReader', (): void => {
|
||||
`, baseUrl);
|
||||
dataMap[target.path] = toQuads(`
|
||||
[]
|
||||
acp:resource <./foo> ;
|
||||
acp:resource <./foo>;
|
||||
acp:accessControl [ acp:apply _:appendPolicy ].
|
||||
_:appendPolicy
|
||||
acp:allow acl:Append;
|
||||
@ -143,7 +143,7 @@ describe('An AcpReader', (): void => {
|
||||
const target2 = { path: joinUrl(baseUrl, 'foo/bar') };
|
||||
dataMap[baseUrl] = toQuads(`
|
||||
[]
|
||||
acp:resource <./> ;
|
||||
acp:resource <./>;
|
||||
acp:memberAccessControl [ acp:apply _:policy ].
|
||||
_:policy
|
||||
acp:allow acl:Read;
|
||||
|
@ -1,18 +1,20 @@
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator';
|
||||
import { BaseResourcesGenerator } from '../../../../src/pods/generate/BaseResourcesGenerator';
|
||||
import type {
|
||||
FileIdentifierMapper,
|
||||
FileIdentifierMapperFactory,
|
||||
ResourceLink,
|
||||
} from '../../../../src/storage/mapping/FileIdentifierMapper';
|
||||
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
|
||||
import { ensureTrailingSlash, trimTrailingSlashes } from '../../../../src/util/PathUtil';
|
||||
import { asyncToArray } from '../../../../src/util/IterableUtil';
|
||||
import { ensureTrailingSlash, joinFilePath, trimTrailingSlashes } from '../../../../src/util/PathUtil';
|
||||
import { readableToQuads, readableToString } from '../../../../src/util/StreamUtil';
|
||||
import { HandlebarsTemplateEngine } from '../../../../src/util/templates/HandlebarsTemplateEngine';
|
||||
import { SimpleSuffixStrategy } from '../../../util/SimpleSuffixStrategy';
|
||||
import { mockFileSystem } from '../../../util/Util';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
class DummyFactory implements FileIdentifierMapperFactory {
|
||||
public async create(base: string, rootFilePath: string): Promise<FileIdentifierMapper> {
|
||||
@ -36,20 +38,12 @@ class DummyFactory implements FileIdentifierMapperFactory {
|
||||
}
|
||||
}
|
||||
|
||||
async function genToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
const arr: T[] = [];
|
||||
for await (const result of iterable) {
|
||||
arr.push(result);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
describe('A TemplatedResourcesGenerator', (): void => {
|
||||
describe('A BaseResourcesGenerator', (): void => {
|
||||
const rootFilePath = '/templates/pod';
|
||||
// Using handlebars engine since it's smaller than any possible dummy
|
||||
const metadataStrategy = new SimpleSuffixStrategy('.meta');
|
||||
let store: jest.Mocked<ResourceStore>;
|
||||
let generator: TemplatedResourcesGenerator;
|
||||
let generator: BaseResourcesGenerator;
|
||||
let cache: { data: any };
|
||||
const template = '<{{webId}}> a <http://xmlns.com/foaf/0.1/Person>.';
|
||||
const location = { path: 'http://test.com/alice/' };
|
||||
@ -61,8 +55,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
hasResource: jest.fn(),
|
||||
} as any;
|
||||
|
||||
generator = new TemplatedResourcesGenerator({
|
||||
templateFolder: rootFilePath,
|
||||
generator = new BaseResourcesGenerator({
|
||||
factory: new DummyFactory(),
|
||||
templateEngine: new HandlebarsTemplateEngine('http://test.com/'),
|
||||
metadataStrategy,
|
||||
@ -72,7 +65,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
|
||||
it('fills in a template with the given options.', async(): Promise<void> => {
|
||||
cache.data = { 'template.hbs': template };
|
||||
const result = await genToArray(generator.generate(location, { webId }));
|
||||
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
|
||||
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
|
||||
const id = { path: `${location.path}template` };
|
||||
expect(identifiers).toEqual([ location, id ]);
|
||||
@ -86,7 +79,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
|
||||
it('creates the necessary containers.', async(): Promise<void> => {
|
||||
cache.data = { container: { container: { 'template.hbs': template }}};
|
||||
const result = await genToArray(generator.generate(location, { webId }));
|
||||
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
|
||||
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
|
||||
const id = { path: `${location.path}container/container/template` };
|
||||
expect(identifiers).toEqual([
|
||||
@ -103,7 +96,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
|
||||
it('copies the file stream directly if no template extension is found.', async(): Promise<void> => {
|
||||
cache.data = { noTemplate: template };
|
||||
const result = await genToArray(generator.generate(location, { webId }));
|
||||
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
|
||||
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
|
||||
const id = { path: `${location.path}noTemplate` };
|
||||
expect(identifiers).toEqual([ location, id ]);
|
||||
@ -119,7 +112,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
cache.data = { '.meta': meta, container: { 'template.meta': meta, template }};
|
||||
|
||||
// Not using options since our dummy template generator generates invalid turtle
|
||||
const result = await genToArray(generator.generate(location, { webId }));
|
||||
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
|
||||
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
|
||||
expect(identifiers).toEqual([
|
||||
location,
|
||||
@ -158,7 +151,7 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
cache.data = { '.meta': meta };
|
||||
store.hasResource = jest.fn().mockResolvedValue(true);
|
||||
|
||||
const result = await genToArray(generator.generate(location, { webId }));
|
||||
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
|
||||
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
|
||||
expect(identifiers).toEqual([
|
||||
{ path: `${location.path}.meta` },
|
||||
@ -169,4 +162,20 @@ describe('A TemplatedResourcesGenerator', (): void => {
|
||||
expect(expQuads).toHaveLength(1);
|
||||
expect(expQuads[0].object.value).toBe('metadata');
|
||||
});
|
||||
|
||||
it('returns no results if the target folder does not exist.', async(): Promise<void> => {
|
||||
const result = await asyncToArray(generator.generate(joinFilePath(rootFilePath, 'nope'), location, { webId }));
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('makes sure the results are sorted.', async(): Promise<void> => {
|
||||
cache.data = { 'template2.hbs': template, 'template1.hbs': template };
|
||||
const result = await asyncToArray(generator.generate(rootFilePath, location, { webId }));
|
||||
const identifiers = result.map((res): ResourceIdentifier => res.identifier);
|
||||
expect(identifiers).toEqual([
|
||||
location,
|
||||
{ path: `${location.path}template1` },
|
||||
{ path: `${location.path}template2` },
|
||||
]);
|
||||
});
|
||||
});
|
26
test/unit/pods/generate/StaticFolderGenerator.test.ts
Normal file
26
test/unit/pods/generate/StaticFolderGenerator.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { Resource } from '../../../../src/pods/generate/ResourcesGenerator';
|
||||
import { StaticFolderGenerator } from '../../../../src/pods/generate/StaticFolderGenerator';
|
||||
import type { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator';
|
||||
|
||||
describe('A StaticFolderGenerator', (): void => {
|
||||
const location: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const options = { foo: 'bar' };
|
||||
const folder = '/data/templates/';
|
||||
let source: jest.Mocked<TemplatedResourcesGenerator>;
|
||||
const response: AsyncIterable<Resource> = {} as any;
|
||||
let generator: StaticFolderGenerator;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
source = {
|
||||
generate: jest.fn().mockReturnValue(response),
|
||||
};
|
||||
generator = new StaticFolderGenerator(source, folder);
|
||||
});
|
||||
|
||||
it('calls the source generator with the stored template folder.', async(): Promise<void> => {
|
||||
expect(generator.generate(location, options)).toBe(response);
|
||||
expect(source.generate).toHaveBeenCalledTimes(1);
|
||||
expect(source.generate).toHaveBeenLastCalledWith(folder, location, options);
|
||||
});
|
||||
});
|
90
test/unit/pods/generate/SubfolderResourcesGenerator.test.ts
Normal file
90
test/unit/pods/generate/SubfolderResourcesGenerator.test.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import type { Resource } from '../../../../src/pods/generate/ResourcesGenerator';
|
||||
import { SubfolderResourcesGenerator } from '../../../../src/pods/generate/SubfolderResourcesGenerator';
|
||||
import type { TemplatedResourcesGenerator } from '../../../../src/pods/generate/TemplatedResourcesGenerator';
|
||||
import { asyncToArray } from '../../../../src/util/IterableUtil';
|
||||
|
||||
async function* yieldResources(resources: Resource[]): AsyncIterable<Resource> {
|
||||
yield* resources;
|
||||
}
|
||||
|
||||
function createResource(path: string): Resource {
|
||||
const identifier = { path };
|
||||
const representation = new BasicRepresentation('data', 'text/plain');
|
||||
return { identifier, representation };
|
||||
}
|
||||
|
||||
describe('A SubfolderResourcesGenerator', (): void => {
|
||||
const templateFolder = '/data/templates/';
|
||||
const identifier = { path: 'http://example.com/foo' };
|
||||
const options = { foo: 'bar' };
|
||||
const subfolders = [ 'base', 'empty', 'extra' ];
|
||||
let baseResources: Resource[];
|
||||
let extraResources: Resource[];
|
||||
let source: jest.Mocked<TemplatedResourcesGenerator>;
|
||||
let generator: SubfolderResourcesGenerator;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
baseResources = [];
|
||||
extraResources = [];
|
||||
|
||||
source = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
generate: jest.fn((folder, loc, opt): AsyncIterable<Resource> => {
|
||||
if (folder.endsWith('base')) {
|
||||
return yieldResources(baseResources);
|
||||
}
|
||||
if (folder.endsWith('extra')) {
|
||||
return yieldResources(extraResources);
|
||||
}
|
||||
return yieldResources([]);
|
||||
}),
|
||||
};
|
||||
|
||||
generator = new SubfolderResourcesGenerator(source, subfolders);
|
||||
});
|
||||
|
||||
it('merges the results of the subfolders into one sorted result.', async(): Promise<void> => {
|
||||
baseResources = [ createResource('a'), createResource('c'), createResource('d'), createResource('f') ];
|
||||
extraResources = [ createResource('b'), createResource('e'), createResource('g') ];
|
||||
|
||||
const resources = await asyncToArray(generator.generate(templateFolder, identifier, options));
|
||||
expect(resources.map((resource): string => resource.identifier.path)).toEqual([
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g',
|
||||
]);
|
||||
expect(source.generate).toHaveBeenCalledTimes(3);
|
||||
expect(source.generate).toHaveBeenNthCalledWith(1, '/data/templates/base', identifier, options);
|
||||
expect(source.generate).toHaveBeenNthCalledWith(2, '/data/templates/empty', identifier, options);
|
||||
expect(source.generate).toHaveBeenNthCalledWith(3, '/data/templates/extra', identifier, options);
|
||||
});
|
||||
|
||||
it('keeps the first result in case of duplicate identifiers.', async(): Promise<void> => {
|
||||
const resource1 = createResource('foo');
|
||||
const resource2 = createResource('foo');
|
||||
baseResources = [ createResource('b'), resource1, createResource('g') ];
|
||||
extraResources = [ createResource('a'), resource2, createResource('h') ];
|
||||
const resources = await asyncToArray(generator.generate(templateFolder, identifier, options));
|
||||
expect(resources.map((resource): string => resource.identifier.path)).toEqual([
|
||||
'a', 'b', 'foo', 'g', 'h',
|
||||
]);
|
||||
expect(resources[2]).toBe(resource1);
|
||||
expect(resource2.representation.data.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('correctly sorts containers.', async(): Promise<void> => {
|
||||
baseResources = [ createResource('/'), createResource('/container/'),
|
||||
createResource('/container/foo.acl'), createResource('README.acl') ];
|
||||
extraResources = [ createResource('/'), createResource('/container/'),
|
||||
createResource('/container/foo'), createResource('README') ];
|
||||
|
||||
const resources = await asyncToArray(generator.generate(templateFolder, identifier, options));
|
||||
expect(resources.map((resource): string => resource.identifier.path)).toEqual([
|
||||
'/',
|
||||
'/container/',
|
||||
'/container/foo',
|
||||
'/container/foo.acl',
|
||||
'README',
|
||||
'README.acl',
|
||||
]);
|
||||
});
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
import { concat, filter, find, map, reduce } from '../../../src/util/IterableUtil';
|
||||
import { asyncToArray, concat, filter, find, map, reduce, sortedAsyncMerge } from '../../../src/util/IterableUtil';
|
||||
|
||||
describe('IterableUtil', (): void => {
|
||||
describe('#map', (): void => {
|
||||
@ -50,4 +50,32 @@ describe('IterableUtil', (): void => {
|
||||
expect((): number => reduce(input, (acc, cur): number => acc + cur)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sortedAsyncMerge', (): void => {
|
||||
it('sorts the iterables.', async(): Promise<void> => {
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
async function* left(): AsyncIterator<number> {
|
||||
yield* [ 1, 3, 5, 7, 9 ];
|
||||
}
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
async function* right(): AsyncIterator<number> {
|
||||
yield* [ 0, 2, 3, 4 ];
|
||||
}
|
||||
await expect(asyncToArray(sortedAsyncMerge([ left(), right() ]))).resolves
|
||||
.toEqual([ 0, 1, 2, 3, 3, 4, 5, 7, 9 ]);
|
||||
});
|
||||
|
||||
it('accepts a custom comparator.', async(): Promise<void> => {
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
async function* left(): AsyncIterator<string> {
|
||||
yield* [ 'apple', 'citrus', 'date' ];
|
||||
}
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
async function* right(): AsyncIterator<string> {
|
||||
yield* [ 'banana', 'donut' ];
|
||||
}
|
||||
await expect(asyncToArray(sortedAsyncMerge([ left(), right() ]))).resolves
|
||||
.toEqual([ 'apple', 'banana', 'citrus', 'date', 'donut' ]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -272,6 +272,14 @@ export function mockFileSystem(rootFilepath?: string, time?: Date): { data: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete folder[name];
|
||||
},
|
||||
async pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const { folder, name } = getFolder(path);
|
||||
return Boolean(folder[name]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
createReadStream(path: string): any {
|
||||
return mockFs.createReadStream(path);
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user