@oddjs/odd
Version:
ODD SDK
647 lines • 27.6 kB
JavaScript
import * as cbor from "@ipld/dag-cbor";
import * as uint8arrays from "uint8arrays";
import { throttle } from "throttle-debounce";
import * as DID from "../did/index.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 } from "../path/index.js";
import { decodeCID } from "../common/index.js";
// FILESYSTEM IMPORTS
import { DEFAULT_AES_ALG } from "./protocol/basic.js";
import { NoPermissionError } from "./errors.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";
// CLASS
export class FileSystem {
constructor({ account, dependencies, eventEmitter, root, localOnly }) {
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) => {
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) {
const { account, dependencies, eventEmitter, localOnly } = opts;
const rootKey = 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, opts) {
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() {
if (this.localOnly)
return;
globalThis.removeEventListener("online", this._whenOnline);
globalThis.removeEventListener("beforeunload", this._beforeLeaving);
}
// POSIX INTERFACE (DIRECTORIES)
// -----------------------------
async ls(path) {
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, options = {}) {
if (Path.isFile(path))
throw new Error("`mkdir` only accepts directory paths");
await this.runMutationOnNode(path, {
public: async (root, 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, content, options = {}) {
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)
: [content];
await this.runOnChildTree(root, relPath, async (tree) => {
links.forEach((link) => {
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)
: [content];
await this.runOnChildTree(node, relPath, async (tree) => {
links.forEach((link) => {
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) {
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) {
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) {
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, to) {
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) {
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) {
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) {
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 = 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() {
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() {
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 }) {
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 }) {
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 = 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 = decodedPayload.cid;
const symmKey = decodedPayload.key;
const symmKeyAlgo = decodedPayload.algo;
// 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);
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, { sharedBy, shareWith }) {
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, 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]]
: 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, isMutation) {
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, handlers) {
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(path, handlers) {
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, relPath, fn) {
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() {
const toPublish = [...this._publishWhenOnline];
this._publishWhenOnline = [];
toPublish.forEach(([cid, proof]) => {
this.publishHooks.forEach(hook => hook(cid, proof));
});
}
/** @internal */
_beforeLeaving(e) {
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;
return msg;
}
}
}
export default FileSystem;
//# sourceMappingURL=filesystem.js.map