src/fs/filesystem.ts
import * as cbor from "@ipld/dag-cbor"
import * as uint8arrays from "uint8arrays"
import { CID } from "multiformats/cid"
import { SymmAlg } from "keystore-idb/lib/types.js"
import { throttle } from "throttle-debounce"
import { Links } from "./types.js"
import { Branch, DistinctivePath, DirectoryPath, FilePath, Path } from "../path.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 cidLog from "../common/cid-log.js"
import * as dataRoot from "../data-root.js"
import * as debug from "../common/debug.js"
import * as crypto from "../crypto/index.js"
import * as did from "../did/index.js"
import * as ipfs from "../ipfs/basic.js"
import * as keystore from "../keystore.js"
import * as pathing from "../path.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"
import * as typeCheck from "./types/check.js"
import * as typeChecks from "../common/type-checks.js"
import * as ucan from "../ucan/index.js"
import { FileContent } from "../ipfs/index.js"
import { NoPermissionError } from "../errors.js"
import { Permissions, appDataPath } from "../ucan/permissions.js"
import { authenticatedUsername, decodeCID } from "../common/index.js"
// TYPES
interface AppPath {
(): DirectoryPath
(path: DirectoryPath): DirectoryPath
(path: FilePath): FilePath
}
type ConstructorParams = {
localOnly?: boolean
permissions?: Permissions
root: RootTree
}
type FileSystemOptions = {
localOnly?: boolean
permissions?: Permissions
}
type NewFileSystemOptions = FileSystemOptions & {
rootKey?: string
}
type MutationOptions = {
publish?: boolean
}
// CLASS
export class FileSystem {
root: RootTree
readonly localOnly: boolean
appPath: AppPath | undefined
proofs: { [_: string]: string }
publishHooks: Array<PublishHook>
_publishWhenOnline: Array<[CID, string]>
_publishing: false | [CID, true]
constructor({ root, permissions, localOnly }: ConstructorParams) {
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)
const globe = (globalThis as any)
globe.filesystems = globe.filesystems || []
globe.filesystems.push(this)
if (
permissions &&
permissions.app &&
permissions.app.creator &&
permissions.app.name
) {
this.appPath = appPath(permissions)
}
// Add the root CID of the file system to the CID log
// (reverse list, newest cid first)
const logCid = async (cid: CID) => {
await cidLog.add(cid.toString())
debug.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 dataRoot.update(cid, proof).then(() => {
if (this._publishing && this._publishing[0] === 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 { permissions, localOnly } = opts
const rootKey = opts.rootKey || await crypto.aes.genKeyStr()
const root = await RootTree.empty({ rootKey })
const fs = new FileSystem({
root,
permissions,
localOnly
})
return fs
}
/**
* Loads an existing file system from a CID.
*/
static async fromCID(cid: CID, opts: FileSystemOptions = {}): Promise<FileSystem | null> {
const { permissions, localOnly } = opts
const root = await RootTree.fromCID({ cid, permissions })
const fs = new FileSystem({
root,
permissions,
localOnly
})
return fs
}
// 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
const globe = (globalThis as any)
globe.filesystems = globe.filesystems.filter((a: FileSystem) => a !== this)
globe.removeEventListener("online", this._whenOnline)
globe.removeEventListener("beforeunload", this._beforeLeaving)
}
// POSIX INTERFACE (DIRECTORIES)
// -----------------------------
async ls(path: DirectoryPath): Promise<Links> {
if (pathing.isFile(path)) throw new Error("`ls` only accepts directory paths")
return this.runOnNode(path, false, (node, relPath) => {
if (typeCheck.isFile(node)) {
throw new Error("Tried to `ls` a file")
} else {
return node.ls(relPath)
}
})
}
async mkdir(path: DirectoryPath, options: MutationOptions = {}): Promise<this> {
if (pathing.isFile(path)) throw new Error("`mkdir` only accepts directory paths")
await this.runOnNode(path, true, (node, relPath) => {
if (typeCheck.isFile(node)) {
throw new Error("Tried to `mkdir` a file")
} else {
return node.mkdir(relPath)
}
})
if (options.publish) {
await this.publish()
}
return this
}
// POSIX INTERFACE (FILES)
// -----------------------
async add(
path: DistinctivePath,
content: FileContent | SoftLink | SoftLink[] | Record<string, SoftLink>,
options: MutationOptions = {}
): Promise<this> {
await this.runOnNode(path, true, async (node, relPath) => {
const destinationIsFile = typeCheck.isFile(node)
const contentIsSoftLinks = typeCheck.isSoftLink(content)
|| typeCheck.isSoftLinkDictionary(content)
|| typeCheck.isSoftLinkList(content)
if (contentIsSoftLinks && destinationIsFile) {
throw new Error("Can't add soft links to a file")
} else if (!contentIsSoftLinks && pathing.isDirectory(path)) {
throw new Error("`add` only accepts file paths when working with regular files")
}
if (destinationIsFile && !contentIsSoftLinks) {
return (node as File).updateContent(content as FileContent)
} else if (contentIsSoftLinks) {
const links = Array.isArray(content)
? content
: typeChecks.isObject(content)
? Object.values(content) as Array<SoftLink>
: [ content ] as Array<SoftLink>
return 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 (!destinationIsFile) {
return (node as Tree).add(relPath, content as FileContent)
}
})
if (options.publish) {
await this.publish()
}
return this
}
async cat(path: FilePath): Promise<FileContent> {
if (pathing.isDirectory(path)) throw new Error("`cat` only accepts file paths")
return this.runOnNode(path, false, async (node, relPath) => {
return typeCheck.isFile(node)
? node.content
: node.cat(relPath)
})
}
async read(path: FilePath): Promise<FileContent | null> {
if (pathing.isDirectory(path)) throw new Error("`read` only accepts file paths")
return this.cat(path)
}
async write(path: FilePath, content: FileContent, options: MutationOptions = {}): Promise<this> {
if (pathing.isDirectory(path)) throw new Error("`write` only accepts file paths")
return this.add(path, content, options)
}
// POSIX INTERFACE (GENERAL)
// -------------------------
async exists(path: DistinctivePath): Promise<boolean> {
return this.runOnNode(path, false, async (node, relPath) => {
return typeCheck.isFile(node)
? true // tried to check the existance of itself
: node.exists(relPath)
})
}
async get(path: DistinctivePath): Promise<Tree | File | null> {
return this.runOnNode(path, false, async (node, relPath) => {
return typeCheck.isFile(node)
? node // tried to get itself
: node.get(relPath)
})
}
// This is only implemented on the same tree for now and will error otherwise
async mv(from: DistinctivePath, to: DistinctivePath): Promise<this> {
const sameTree = pathing.isSameBranch(from, to)
if (!pathing.isSameKind(from, to)) {
const kindFrom = pathing.kind(from)
const kindTo = pathing.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.runOnNode(from, true, (node, relPath) => {
if (typeCheck.isFile(node)) {
throw new Error("Tried to `mv` within a file")
}
const [ _, ...nextPath ] = pathing.unwrap(to)
return node.mv(relPath, nextPath)
})
return this
}
async rm(path: DistinctivePath): Promise<this> {
await this.runOnNode(path, true, (node, relPath) => {
if (typeCheck.isFile(node)) {
throw new Error("Cannot `rm` a file you've asked permission for")
} else {
return node.rm(relPath)
}
})
return this
}
/**
* Make an internal symbolic link **at** a path.
*/
async symlink(
args:
{ at: DirectoryPath; referringTo: DistinctivePath; name: string; username?: string }
): Promise<this> {
const { at, referringTo, name } = args
if (at == null) throw new Error("Missing parameter `symlink.at`")
if (pathing.isFile(at)) throw new Error("`symlink.at` only accepts directory paths")
const username = args.username || await authenticatedUsername()
const sameTree = pathing.isSameBranch(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")
const canShare = ucan.dictionary.lookupFilesystemUcan(
pathing.directory(pathing.Branch.Shared)
)
if (!canShare) throw new Error("Not allowed to share private items")
await this.runOnNode(at, true, async (node, relPath) => {
if (typeCheck.isFile(node)) {
throw new Error("Cannot add a soft link to a file")
}
return this.runOnChildTree(node, relPath, async tree => {
if (PrivateTree.instanceOf(tree)) {
const destNode: PrivateTree | PrivateFile | null = await this.runOnNode(referringTo, false, async (a, relPath) => {
const b = typeCheck.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()
})
} else if (PublicTree.instanceOf(tree)) {
tree.insertSoftLink({
path: pathing.removeBranch(referringTo),
name,
username
})
}
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
}
// 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.add(
pathing.directory(Branch.Private, "Shared with me", sharedBy),
await share.ls([])
)
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<PrivateTree> {
const ourExchangeDid = await did.exchange()
const theirRootDid = await did.root(sharedBy)
// Share key
const key = await shareKey.create({
counter: parseInt(shareId, 10),
recipientExchangeDid: ourExchangeDid,
senderRootDid: theirRootDid
})
// Load their shared section
const root = await dataRoot.lookup(sharedBy)
if (!root) throw new Error("This user doesn't have a filesystem yet.")
const rootLinks = await protocol.basic.getSimpleLinks(root)
const sharedLinksCid = rootLinks[Branch.Shared]?.cid || null
if (!sharedLinksCid) throw new Error("This user hasn't shared anything yet.")
const sharedLinks = await RootTree.getSharedLinks(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 ipfs.catBuf(decodeCID(shareLinkCid))
// Decode payload
const ks = await keystore.get()
const exchangeKey = await ks.exchangeKey()
if (!exchangeKey.privateKey) throw new Error("Missing private key in exchange key-pair")
const decryptedPayload = await crypto.rsa.decrypt(sharePayload, exchangeKey.privateKey)
const decodedPayload: Record<string, unknown> = cbor.decode(new Uint8Array(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: string = uint8arrays.toString(decodedPayload.key as Uint8Array, "base64pad")
const symmKeyAlgo: string = decodedPayload.algo as string
// Load MMPT
const mmptCid = rootLinks[Branch.Private]?.cid
if (!mmptCid) throw new Error("This user's filesystem doesn't have a private branch")
const theirMmpt = await MMPT.fromCID(decodeCID(rootLinks[Branch.Private]?.cid))
// Decode index
const encryptedIndex = await ipfs.catBuf(decodeCID(entryIndexCid))
const indexInfoBytes = await 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
const index = await PrivateTree.fromInfo(theirMmpt, symmKey, indexInfo)
return index
}
/**
* Share a private file with a user.
*/
async sharePrivate(paths: DistinctivePath[], { sharedBy, shareWith }: { sharedBy?: SharedBy; shareWith: string | string[] }): Promise<ShareDetails> {
const verifiedPaths = paths.filter(path => {
return pathing.isBranch(pathing.Branch.Private, path)
})
// Our username
if (!sharedBy) {
const username = await authenticatedUsername()
if (!username) throw new Error("I need a username in order to use this method")
sharedBy = { rootDid: await did.ownRoot(), username }
}
// Get the items to share
const items = await verifiedPaths.reduce(async (promise: Promise<[string, PrivateFile | PrivateTree][]>, path) => {
const acc = await promise
const name = pathing.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.root,
items,
{ shareWith, sharedBy }
)
// Bump the counter
await this.root.bumpSharedCounter()
// Publish
await this.root.updatePuttable(Branch.Private, this.root.mmpt)
await this.publish()
// Fin
return shareDetails
}
// COMMON
// ------
/**
* Stores the public part of the exchange key in the DID format,
* in the `/public/.well-known/exchange/DID_GOES_HERE/` directory.
*/
async addPublicExchangeKey(): Promise<void> {
const publicDid = await did.exchange()
await this.mkdir(
pathing.combine(sharing.EXCHANGE_PATH, pathing.directory(publicDid))
)
}
/**
* Checks if the public exchange key was added in the well-known location.
* See `addPublicExchangeKey()` for the exact details.
*/
async hasPublicExchangeKey(): Promise<boolean> {
const publicDid = await did.exchange()
return this.exists(
pathing.combine(sharing.EXCHANGE_PATH, pathing.directory(publicDid))
)
}
/**
* 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(link)
} else {
return PublicTree.resolveSoftLink(link)
}
}
// INTERNAL
// --------
/** @internal */
async runOnNode<a>(
path: DistinctivePath,
isMutation: boolean,
fn: (node: Tree | File, relPath: Path) => Promise<a>
): Promise<a> {
const parts = pathing.unwrap(path)
const head = parts[0]
const relPath = parts.slice(1)
const operation = isMutation
? "make changes to"
: "query"
if (!this.localOnly) {
const proof = await ucan.dictionary.lookupFilesystemUcan(path)
const decodedProof = proof && ucan.decode(proof)
if (!proof || !decodedProof || ucan.isExpired(decodedProof) || !decodedProof.signature) {
throw new NoPermissionError(`I don't have the necessary permissions to ${operation} the file system at "${pathing.toPosix(path)}"`)
}
this.proofs[decodedProof.signature] = proof
}
let result: a
let resultPretty: a
if (head === Branch.Public) {
result = await fn(this.root.publicTree, relPath)
if (isMutation && PublicTree.instanceOf(result)) {
resultPretty = await fn(this.root.prettyTree, relPath)
this.root.publicTree = result
this.root.prettyTree = resultPretty as unknown as BareTree
await Promise.all([
this.root.updatePuttable(Branch.Public, this.root.publicTree),
this.root.updatePuttable(Branch.Pretty, this.root.prettyTree)
])
}
} else if (head === Branch.Private) {
const [nodePath, node] = this.root.findPrivateNode(
path
)
if (!node) {
throw new NoPermissionError(`I don't have the necessary permissions to ${operation} the file system at "${pathing.toPosix(path)}"`)
}
result = await fn(
node,
parts.slice(pathing.unwrap(nodePath).length)
)
if (
isMutation &&
(PrivateTree.instanceOf(result) || PrivateFile.instanceOf(result))
) {
this.root.privateNodes[pathing.toPosix(nodePath)] = result
await result.put()
await this.root.updatePuttable(Branch.Private, this.root.mmpt)
const cid = await this.root.mmpt.put()
await this.root.addPrivateLogEntry(cid)
}
} else if (head === Branch.Pretty && isMutation) {
throw new Error("The pretty path is read only")
} else if (head === Branch.Pretty) {
result = await fn(this.root.prettyTree, relPath)
} else {
throw new Error("Not a valid FileSystem path")
}
return result
}
/** @internal
* `put` should be called on the node returned from the function.
* Normally this is handled by `runOnNode`.
*/
async runOnChildTree(node: Tree, relPath: Path, 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 (typeCheck.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
// ㊙️
function appPath(permissions: Permissions): AppPath {
if (!permissions.app) throw Error("Only works with app permissions")
const base = appDataPath(permissions.app)
return ((path?: DistinctivePath) => {
if (path) return pathing.combine(base, path)
return base
}) as unknown as AppPath
}