UNPKG

@oddjs/odd

Version:
858 lines (702 loc) 27.9 kB
import * as cbor from "@ipld/dag-cbor" import * as uint8arrays from "uint8arrays" import { CID } from "multiformats/cid" import { throttle } from "throttle-debounce" import * as Crypto from "../components/crypto/implementation.js" import * as Depot from "../components/depot/implementation.js" import * as Manners from "../components/manners/implementation.js" import * as Reference from "../components/reference/implementation.js" import * as Storage from "../components/storage/implementation.js" import * as DID from "../did/index.js" import * as Events from "../events.js" import * as FsTypeChecks from "./types/check.js" import * as Path from "../path/index.js" import * as TypeChecks from "../common/type-checks.js" import * as Ucan from "../ucan/index.js" import * as Versions from "./versions.js" import { RootBranch, Partitioned, PartitionedNonEmpty, Partition, DistinctivePath } from "../path/index.js" import { EventEmitter } from "../events.js" import { Permissions } from "../permissions.js" import { SymmAlg } from "../components/crypto/implementation.js" import { decodeCID } from "../common/index.js" // FILESYSTEM IMPORTS import { DEFAULT_AES_ALG } from "./protocol/basic.js" import { API, AssociatedIdentity, Links, PuttableUnixTree, UnixTree } from "./types.js" import { NoPermissionError } from "./errors.js" import { PublishHook, Tree, File, SharedBy, ShareDetails, SoftLink } from "./types.js" import BareTree from "./bare/tree.js" import MMPT from "./protocol/private/mmpt.js" import RootTree from "./root/tree.js" import PublicTree from "./v1/PublicTree.js" import PrivateFile from "./v1/PrivateFile.js" import PrivateTree from "./v1/PrivateTree.js" import * as PrivateTypeChecks from "./protocol/private/types/check.js" import * as Protocol from "./protocol/index.js" import * as ShareKey from "./protocol/shared/key.js" import * as Sharing from "./share.js" // TYPES export type Dependencies = { crypto: Crypto.Implementation depot: Depot.Implementation manners: Manners.Implementation reference: Reference.Implementation storage: Storage.Implementation } export type FileSystemOptions = { account: AssociatedIdentity dependencies: Dependencies eventEmitter: EventEmitter<Events.FileSystem> localOnly?: boolean permissions?: Permissions } export type MutationOptions = { publish?: boolean } export type NewFileSystemOptions = FileSystemOptions & { rootKey?: Uint8Array version?: string } type ConstructorParams = { account: AssociatedIdentity dependencies: Dependencies eventEmitter: EventEmitter<Events.FileSystem> localOnly?: boolean root: RootTree } // CLASS export class FileSystem implements API { account: AssociatedIdentity dependencies: Dependencies eventEmitter: EventEmitter<Events.FileSystem> root: RootTree readonly localOnly: boolean proofs: { [ _: string ]: Ucan.Ucan } publishHooks: Array<PublishHook> _publishWhenOnline: Array<[ CID, Ucan.Ucan ]> _publishing: false | [ CID, true ] constructor({ account, dependencies, eventEmitter, root, localOnly }: ConstructorParams) { this.account = account this.dependencies = dependencies this.eventEmitter = eventEmitter this.localOnly = localOnly || false this.proofs = {} this.publishHooks = [] this.root = root this._publishWhenOnline = [] this._publishing = false this._whenOnline = this._whenOnline.bind(this) this._beforeLeaving = this._beforeLeaving.bind(this) // Add the root CID of the file system to the CID log // (reverse list, newest cid first) const logCid = async (cid: CID) => { await this.dependencies.reference.repositories.cidLog.add(cid) this.dependencies.manners.log("📓 Adding to the CID ledger:", cid.toString()) } // Update the user's data root when making changes const updateDataRootWhenOnline = throttle(3000, false, (cid, proof) => { if (globalThis.navigator.onLine) { this._publishing = [ cid, true ] return this.dependencies.reference.dataRoot.update(cid, proof).then(() => { if (this._publishing && this._publishing[ 0 ] === cid) { eventEmitter.emit("fileSystem:publish", { root: cid }) this._publishing = false } }) } this._publishWhenOnline.push([ cid, proof ]) }, false) this.publishHooks.push(logCid) this.publishHooks.push(updateDataRootWhenOnline) if (!this.localOnly) { // Publish when coming back online globalThis.addEventListener("online", this._whenOnline) // Show an alert when leaving the page while updating the data root globalThis.addEventListener("beforeunload", this._beforeLeaving) } } // INITIALISATION // -------------- /** * Creates a file system with an empty public tree & an empty private tree at the root. */ static async empty(opts: NewFileSystemOptions): Promise<FileSystem> { const { account, dependencies, eventEmitter, localOnly } = opts const rootKey: Uint8Array = opts.rootKey || await ( dependencies .crypto.aes.genKey(DEFAULT_AES_ALG) .then(dependencies.crypto.aes.exportKey) ) // Create a file system based on wnfs-wasm when this option is set: const wnfsWasm = opts.version === Versions.toString(Versions.wnfsWasm) const root = await RootTree.empty({ accountDID: account.rootDID, dependencies, rootKey, wnfsWasm }) return new FileSystem({ account, dependencies, eventEmitter, root, localOnly }) } /** * Loads an existing file system from a CID. */ static async fromCID(cid: CID, opts: FileSystemOptions): Promise<FileSystem> { const { account, dependencies, eventEmitter, permissions, localOnly } = opts const root = await RootTree.fromCID({ accountDID: account.rootDID, dependencies, cid, permissions }) return new FileSystem({ account, dependencies, eventEmitter, root, localOnly }) } // DEACTIVATE // ---------- /** * Deactivate a file system. * * Use this when a user signs out. * The only function of this is to stop listing to online/offline events. */ deactivate(): void { if (this.localOnly) return globalThis.removeEventListener("online", this._whenOnline) globalThis.removeEventListener("beforeunload", this._beforeLeaving) } // POSIX INTERFACE (DIRECTORIES) // ----------------------------- async ls(path: Path.Directory<Partitioned<Partition>>): Promise<Links> { if (Path.isFile(path)) throw new Error("`ls` only accepts directory paths") return this.runOnNode(path, { public: async (root, relPath) => { return root.ls(relPath) }, private: async (node, relPath) => { if (FsTypeChecks.isFile(node)) { throw new Error("Tried to `ls` a file") } else { return node.ls(relPath) } } }) } async mkdir(path: Path.Directory<PartitionedNonEmpty<Partition>>, options: MutationOptions = {}): Promise<this> { if (Path.isFile(path)) throw new Error("`mkdir` only accepts directory paths") await this.runMutationOnNode(path, { public: async (root: BareTree, relPath) => { await root.mkdir(relPath) }, private: async (node, relPath) => { if (FsTypeChecks.isFile(node)) { throw new Error("Tried to `mkdir` a file") } else { await node.mkdir(relPath) } } }) if (options.publish) { await this.publish() } return this } // POSIX INTERFACE (FILES) // ----------------------- async write( path: Path.Distinctive<Partitioned<Partition>>, content: Uint8Array | SoftLink | SoftLink[] | Record<string, SoftLink>, options: MutationOptions = {} ): Promise<this> { const contentIsSoftLinks = FsTypeChecks.isSoftLink(content) || FsTypeChecks.isSoftLinkDictionary(content) || FsTypeChecks.isSoftLinkList(content) if (contentIsSoftLinks) { if (Path.isFile(path)) { throw new Error("Can't add soft links to a file") } await this.runMutationOnNode(path, { public: async (root, relPath) => { const links = Array.isArray(content) ? content : TypeChecks.isObject(content) ? Object.values(content) as Array<SoftLink> : [ content ] as Array<SoftLink> await this.runOnChildTree(root as Tree, relPath, async tree => { links.forEach((link: SoftLink) => { if (PrivateTree.instanceOf(tree) || PublicTree.instanceOf(tree)) tree.assignLink({ name: link.name, link: link, skeleton: link }) }) return tree }) }, private: async (node, relPath) => { const links = Array.isArray(content) ? content : TypeChecks.isObject(content) ? Object.values(content) as Array<SoftLink> : [ content ] as Array<SoftLink> await this.runOnChildTree(node as Tree, relPath, async tree => { links.forEach((link: SoftLink) => { if (PrivateTree.instanceOf(tree) || PublicTree.instanceOf(tree)) tree.assignLink({ name: link.name, link: link, skeleton: link }) }) return tree }) } }) } else { if (Path.isDirectory(path)) { throw new Error("`add` only accepts file paths when working with regular files") } await this.runMutationOnNode(path, { public: async (root, relPath) => { await root.add(relPath, content) }, private: async (node, relPath) => { const destinationIsFile = FsTypeChecks.isFile(node) if (destinationIsFile) { await node.updateContent(content) } else { await node.add(relPath, content) } } }) } if (options.publish) { await this.publish() } return this } async read(path: Path.File<PartitionedNonEmpty<Partition>>): Promise<Uint8Array> { if (Path.isDirectory(path)) throw new Error("`cat` only accepts file paths") return this.runOnNode(path, { public: async (root, relPath) => { return await root.cat(relPath) }, private: async (node, relPath) => { return FsTypeChecks.isFile(node) ? node.content : await node.cat(relPath) } }) } // POSIX INTERFACE (GENERAL) // ------------------------- async exists(path: Path.Distinctive<PartitionedNonEmpty<Partition>>): Promise<boolean> { return this.runOnNode(path, { public: async (root, relPath) => { return await root.exists(relPath) }, private: async (node, relPath) => { // node is a file, then we tried to check the existance of itself return FsTypeChecks.isFile(node) || await node.exists(relPath) } }) } async get(path: Path.Distinctive<Partitioned<Partition>>): Promise<PuttableUnixTree | File | null> { return this.runOnNode(path, { public: async (root, relPath) => { return await root.get(relPath) }, private: async (node, relPath) => { return FsTypeChecks.isFile(node) ? node // tried to get itself : await node.get(relPath) } }) } // This is only implemented on the same tree for now and will error otherwise async mv(from: Path.Distinctive<PartitionedNonEmpty<Partition>>, to: Path.Distinctive<PartitionedNonEmpty<Partition>>): Promise<this> { const sameTree = Path.isSamePartition(from, to) if (!Path.isSameKind(from, to)) { const kindFrom = Path.kind(from) const kindTo = Path.kind(to) throw new Error(`Can't move to a different kind of path, from is a ${kindFrom} and to is a ${kindTo}`) } if (!sameTree) { throw new Error("`mv` is only supported on the same tree for now") } if (await this.exists(to)) { throw new Error("Destination already exists") } await this.runMutationOnNode(from, { public: async (root, relPath) => { const [ _, ...nextPath ] = Path.unwrap(to) await root.mv(relPath, nextPath) }, private: async (node, relPath) => { if (FsTypeChecks.isFile(node)) { throw new Error("Tried to `mv` within a file") } const [ _, ...nextPath ] = Path.unwrap(to) // TODO FIXME: nextPath is wrong if you use a node that's deeper in the tree. await node.mv(relPath, nextPath) } }) return this } /** * Resolve a symlink directly. * The `get` and `cat` methods will automatically resolve symlinks, * but sometimes when working with symlinks directly * you might want to use this method instead. */ resolveSymlink(link: SoftLink): Promise<File | Tree | null> { if (TypeChecks.hasProp(link, "privateName")) { return PrivateTree.resolveSoftLink(this.dependencies.crypto, this.dependencies.depot, this.dependencies.manners, this.dependencies.reference, link) } else { return PublicTree.resolveSoftLink(this.dependencies.depot, this.dependencies.reference, link) } } async rm(path: DistinctivePath<Partitioned<Partition>>): Promise<this> { await this.runMutationOnNode(path, { public: async (root, relPath) => { await root.rm(relPath) }, private: async (node, relPath) => { if (FsTypeChecks.isFile(node)) { throw new Error("Cannot `rm` a file you've asked permission for") } else { await node.rm(relPath) } } }) return this } /** * Make a symbolic link **at** a path. */ async symlink(args: { at: Path.Directory<Partitioned<Partition>> referringTo: { path: Path.Distinctive<Partitioned<Partition>> username?: string } name: string }): Promise<this> { const { at, name } = args const referringTo = args.referringTo.path const username = args.referringTo.username || this.account.username if (at == null) throw new Error("Missing parameter `symlink.at`") if (Path.isFile(at)) throw new Error("`symlink.at` only accepts directory paths") const sameTree = Path.isSamePartition(at, referringTo) if (!username) throw new Error("I need a username in order to use this method") if (!sameTree) throw new Error("`link` is only supported on the same tree for now") await this.runMutationOnNode(at, { public: async (root, relPath) => { // Skip the pretty tree, we don't need to attach the symlink to that. if (BareTree.instanceOf(root)) return if (!PublicTree.instanceOf(root)) { // TODO throw new Error(`Symlinks not supported in WASM-WNFS yet.`) } else { await this.runOnChildTree(root, relPath, async tree => { if (PublicTree.instanceOf(tree)) { tree.insertSoftLink({ path: Path.removePartition(referringTo), name, username, }) } return tree }) } }, private: async (node, relPath) => { if (FsTypeChecks.isFile(node)) { throw new Error("Cannot add a soft link to a file") } await this.runOnChildTree(node, relPath, async tree => { if (PrivateTree.instanceOf(tree)) { const destNode: PrivateTree | PrivateFile | null = await this.runOnNode(referringTo, { public: async () => { // This should be impossible at the moment throw new Error(`File system hit a public node within a private node. This is not supported/this should not happen.`) }, private: async (a, relPath) => { const b = FsTypeChecks.isFile(a) ? a : await a.get(relPath) if (PrivateTree.instanceOf(b)) return b else if (PrivateFile.instanceOf(b)) return b else throw new Error("`symlink.referringTo` is not of the right type") } }) if (!destNode) throw new Error("Could not find the item the symlink is referring to") tree.insertSoftLink({ name, username, key: destNode.key, privateName: await destNode.getName() }) } return tree }) } }) return this } // PUBLISH // ------- /** * Ensures the latest version of the file system is added to IPFS, * updates your data root, and returns the root CID. */ async publish(): Promise<CID> { const proofs = Array.from(Object.entries(this.proofs)) this.proofs = {} const cid = await this.root.put() proofs.forEach(([ _, proof ]) => { this.publishHooks.forEach(hook => hook(cid, proof)) }) return cid } // HISTORY STEPPING // ---------------- /** * Ensures the current version of your file system is "committed" * and stepped forward, so the current version will always be * persisted as an "step" in the history of the file system. * * This function is implicitly called every time your file system * changes are synced, so in most cases calling this is handled * for you. */ async historyStep(): Promise<void> { const publicTree = this.root.publicTree if (TypeChecks.hasProp(publicTree, "historyStep") && typeof publicTree.historyStep === "function") { // this function is not available in lower versions. await publicTree.historyStep() } } // SHARING // ------- /** * Accept a share. * Copies the links to the items into your 'Shared with me' directory. * eg. `private/Shared with me/Sharer/` */ async acceptShare({ shareId, sharedBy }: { shareId: string; sharedBy: string }): Promise<this> { const share = await this.loadShare({ shareId, sharedBy }) await this.write( Path.directory(RootBranch.Private, "Shared with me", sharedBy), await share.ls([]).then(Object.values).then(links => links.filter(FsTypeChecks.isSoftLink)) ) return this } /** * Loads a share. * Returns a "entry index", in other words, * a private tree with symlinks (soft links) to the shared items. */ async loadShare({ shareId, sharedBy }: { shareId: string; sharedBy: string }): Promise<UnixTree> { const ourExchangeDid = await DID.exchange(this.dependencies.crypto) const theirRootDid = await this.dependencies.reference.didRoot.lookup(sharedBy) // Share key const key = await ShareKey.create(this.dependencies.crypto, { counter: parseInt(shareId, 10), recipientExchangeDid: ourExchangeDid, senderRootDid: theirRootDid }) // Load their shared section const root = await this.dependencies.reference.dataRoot.lookup(sharedBy) if (!root) throw new Error("This user doesn't have a filesystem yet.") const rootLinks = await Protocol.basic.getSimpleLinks(this.dependencies.depot, root) const sharedLinksCid = rootLinks[ RootBranch.Shared ]?.cid || null if (!sharedLinksCid) throw new Error("This user hasn't shared anything yet.") const sharedLinks = await RootTree.getSharedLinks(this.dependencies.depot, decodeCID(sharedLinksCid)) const shareLink = TypeChecks.isObject(sharedLinks) ? sharedLinks[ key ] : null if (!shareLink) throw new Error("Couldn't find a matching share.") const shareLinkCid = TypeChecks.isObject(shareLink) ? shareLink.cid : null if (!shareLinkCid) throw new Error("Couldn't find a matching share.") const sharePayload = await this.dependencies.depot.getBlock(decodeCID(shareLinkCid)) // Decode payload const decryptedPayload = await this.dependencies.crypto.keystore.decrypt(sharePayload) const decodedPayload: Record<string, unknown> = cbor.decode(decryptedPayload) if (!TypeChecks.hasProp(decodedPayload, "cid")) throw new Error("Share payload is missing the `cid` property") if (!TypeChecks.hasProp(decodedPayload, "key")) throw new Error("Share payload is missing the `key` property") if (!TypeChecks.hasProp(decodedPayload, "algo")) throw new Error("Share payload is missing the `algo` property") const entryIndexCid: string = decodedPayload.cid as string const symmKey: Uint8Array = decodedPayload.key as Uint8Array const symmKeyAlgo: string = decodedPayload.algo as string // Load MMPT const mmptCid = rootLinks[ RootBranch.Private ]?.cid if (!mmptCid) throw new Error("This user's filesystem doesn't have a private branch") const theirMmpt = await MMPT.fromCID( this.dependencies.depot, decodeCID(rootLinks[ RootBranch.Private ]?.cid) ) // Decode index const encryptedIndex = await this.dependencies.depot.getBlock(decodeCID(entryIndexCid)) const indexInfoBytes = await this.dependencies.crypto.aes.decrypt(encryptedIndex, symmKey, symmKeyAlgo as SymmAlg) const indexInfo = JSON.parse(uint8arrays.toString(indexInfoBytes, "utf8")) if (!PrivateTypeChecks.isDecryptedNode(indexInfo)) throw new Error("The share payload did not point to a valid entry index") // Load index and return it return PrivateTree.fromInfo( this.dependencies.crypto, this.dependencies.depot, this.dependencies.manners, this.dependencies.reference, theirMmpt, symmKey, indexInfo) } /** * Share a private file with a user. */ async sharePrivate(paths: Path.Distinctive<Path.PartitionedNonEmpty<Path.Private>>[], { sharedBy, shareWith }: { sharedBy?: SharedBy; shareWith: string | string[] }): Promise<ShareDetails> { const verifiedPaths = paths.filter(path => { return Path.isOnRootBranch(Path.RootBranch.Private, path) }) // Our username if (!sharedBy) { if (!this.account.username) throw new Error("I need a username in order to use this method") sharedBy = { rootDid: this.account.rootDID, username: this.account.username } } // Get the items to share const items = await verifiedPaths.reduce(async (promise: Promise<[ string, PrivateFile | PrivateTree ][]>, path) => { const acc = await promise const name = Path.terminus(path) const item = await this.get(path) return name && (PrivateFile.instanceOf(item) || PrivateTree.instanceOf(item)) ? [ ...acc, [ name, item ] as [ string, PrivateFile | PrivateTree ] ] : acc }, Promise.resolve([])) // No items? if (!items.length) throw new Error("Didn't find any items to share") // Share the items const shareDetails = await Sharing.privateNode( this.dependencies.crypto, this.dependencies.depot, this.dependencies.manners, this.dependencies.reference, this.root, items, { shareWith, sharedBy } ) // Bump the counter await this.root.bumpSharedCounter() // Publish await this.root.updatePuttable(RootBranch.Private, this.root.mmpt) await this.publish() // Fin return shareDetails } // INTERNAL // -------- /** @internal */ async checkMutationPermissionAndAddProof(path: DistinctivePath<Partitioned<Partition>>, isMutation: boolean): Promise<void> { const operation = isMutation ? "make changes to" : "query" if (!this.localOnly) { const proof = await this.dependencies.reference.repositories.ucans.lookupFilesystemUcan(path) if (!proof || Ucan.isExpired(proof) || !proof.signature) { throw new NoPermissionError(`I don't have the necessary permissions to ${operation} the file system at "${Path.toPosix(path)}"`) } this.proofs[ proof.signature ] = proof } } /** @internal */ async runMutationOnNode( path: DistinctivePath<Partitioned<Partition>>, handlers: { public(root: UnixTree, relPath: Path.Segments): Promise<void> private(node: PrivateTree | PrivateFile, relPath: Path.Segments): Promise<void> }, ): Promise<void> { const parts = Path.unwrap(path) const head = parts[ 0 ] const relPath = parts.slice(1) await this.checkMutationPermissionAndAddProof(path, true) if (head === RootBranch.Public) { await handlers.public(this.root.publicTree, relPath) await handlers.public(this.root.prettyTree, relPath) await Promise.all([ this.root.updatePuttable(RootBranch.Public, this.root.publicTree), this.root.updatePuttable(RootBranch.Pretty, this.root.prettyTree) ]) } else if (head === RootBranch.Private) { const [ nodePath, node ] = this.root.findPrivateNode(path) if (!node) { throw new NoPermissionError(`I don't have the necessary permissions to make changes to the file system at "${Path.toPosix(path)}"`) } await handlers.private(node, parts.slice(Path.unwrap(nodePath).length)) await node.put() await this.root.updatePuttable(RootBranch.Private, this.root.mmpt) const cid = await this.root.mmpt.put() await this.root.addPrivateLogEntry(this.dependencies.depot, cid) } else if (head === RootBranch.Pretty) { throw new Error("The pretty path is read only") } else { throw new Error("Not a valid FileSystem path") } this.eventEmitter.emit( "fileSystem:local-change", { root: await this.root.put(), path } ) } /** @internal */ async runOnNode<A>( path: DistinctivePath<Partitioned<Partition>>, handlers: { public(root: UnixTree, relPath: Path.Segments): Promise<A> private(node: Tree | File, relPath: Path.Segments): Promise<A> }, ): Promise<A> { const parts = Path.unwrap(path) const head = parts[ 0 ] const relPath = parts.slice(1) await this.checkMutationPermissionAndAddProof(path, false) if (head === RootBranch.Public) { return await handlers.public(this.root.publicTree, relPath) } else if (head === RootBranch.Private) { const [ nodePath, node ] = this.root.findPrivateNode(path) if (!node) { throw new NoPermissionError(`I don't have the necessary permissions to query the file system at "${Path.toPosix(path)}"`) } return await handlers.private(node, parts.slice(Path.unwrap(nodePath).length)) } else if (head === RootBranch.Pretty) { return await handlers.public(this.root.prettyTree, relPath) } else { throw new Error("Not a valid FileSystem path") } } /** @internal * `put` should be called on the node returned from the function. * Normally this is handled by `runOnNode`. */ async runOnChildTree(node: Tree, relPath: Path.Segments, fn: (tree: Tree) => Promise<Tree>): Promise<Tree> { let tree = node if (relPath.length) { if (!await tree.exists(relPath)) await tree.mkdir(relPath) const g = await tree.get(relPath) if (FsTypeChecks.isTree(g)) tree = g else throw new Error("Path does not point to a directory") } tree = await fn(tree) if (relPath.length) return await node.updateChild(tree, relPath) return node } /** @internal */ _whenOnline(): void { const toPublish = [ ...this._publishWhenOnline ] this._publishWhenOnline = [] toPublish.forEach(([ cid, proof ]) => { this.publishHooks.forEach(hook => hook(cid, proof)) }) } /** @internal */ _beforeLeaving(e: Event): void | string { const msg = "Are you sure you want to leave? We don't control the browser so you may lose your data." if (this._publishing || this._publishWhenOnline.length) { (e || globalThis.event).returnValue = msg as any return msg } } } export default FileSystem