src/filesystem.ts
import { CID } from "multiformats/cid"
import * as Crypto from "./components/crypto/implementation.js"
import * as Depot from "./components/depot/implementation.js"
import * as DID from "./did/index.js"
import * as Events from "./events.js"
import * as Protocol from "./fs/protocol/index.js"
import * as Reference from "./components/reference/implementation.js"
import * as RootKey from "./common/root-key.js"
import * as Storage from "./components/storage/implementation.js"
import * as Ucan from "./ucan/index.js"
import * as Versions from "./fs/versions.js"
import { AuthenticationStrategy } from "./index.js"
import { RootBranch } from "./path/index.js"
import { Configuration } from "./configuration.js"
import { Dependencies } from "./fs/filesystem.js"
import { Maybe, decodeCID, EMPTY_CID } from "./common/index.js"
import { type RecoverFileSystemParams } from "./fs/types/params.js"
import FileSystem from "./fs/filesystem.js"
/**
* Load a user's file system.
*/
export async function loadFileSystem({ config, dependencies, eventEmitter, rootKey, username }: {
config: Configuration
dependencies: Dependencies
eventEmitter: Events.Emitter<Events.FileSystem>
rootKey?: Uint8Array
username: string
}): Promise<FileSystem> {
const { crypto, depot, manners, reference, storage } = dependencies
let cid: Maybe<CID>
let fs
// Repositories
const cidLog = reference.repositories.cidLog
// Account
const account = { username, rootDID: await reference.didRoot.lookup(username) }
// Determine the correct CID of the file system to load
const dataCid = navigator.onLine ? await getDataRoot(reference, username, { maxRetries: 20 }) : null
const logIdx = dataCid ? cidLog.indexOf(dataCid) : -1
if (!navigator.onLine) {
// Offline, use local CID
cid = cidLog.newest()
if (cid) manners.log("📓 Working offline, using local CID:", cid.toString())
throw new Error("Offline, don't have a filesystem to work with.")
} else if (!dataCid) {
// No DNS CID yet
cid = cidLog.newest()
if (cid) manners.log("📓 No DNSLink, using local CID:", cid.toString())
else manners.log("📓 Creating a new file system")
} else if (logIdx === cidLog.length() - 1) {
// DNS is up to date
cid = dataCid
manners.log("📓 DNSLink is up to date:", cid.toString())
} else if (logIdx !== -1 && logIdx < cidLog.length() - 1) {
// DNS is outdated
cid = cidLog.newest()
const diff = cidLog.length() - 1 - logIdx
const idxLog = diff === 1 ? "1 newer local entry" : diff.toString() + " newer local entries"
manners.log("📓 DNSLink is outdated (" + idxLog + "), using local CID:", cid.toString())
} else {
// DNS is newer
cid = dataCid
await cidLog.add(cid)
manners.log("📓 DNSLink is newer:", cid.toString())
// TODO: We could test the filesystem version at this DNSLink at this point to figure out whether to continue locally.
// However, that needs a plan for reconciling local changes back into the DNSLink, once migrated. And a plan for migrating changes
// that are only stored locally.
}
// If a file system exists, load it and return it
const p = config.permissions
const dataComponents = { crypto, depot, reference, storage }
if (cid) {
await checkFileSystemVersion(dependencies.depot, config, cid)
await manners.fileSystem.hooks.beforeLoadExisting(cid, account, dataComponents)
fs = await FileSystem.fromCID(cid, { account, dependencies, eventEmitter, permissions: p })
await manners.fileSystem.hooks.afterLoadExisting(fs, account, dataComponents)
return fs
}
// Otherwise make a new one
await manners.fileSystem.hooks.beforeLoadNew(account, dataComponents)
fs = await FileSystem.empty({
account,
dependencies,
eventEmitter,
rootKey,
permissions: p,
version: config.fileSystem?.version
})
await manners.fileSystem.hooks.afterLoadNew(fs, account, dataComponents)
// Fin
return fs
}
/**
* Recover a user's file system.
*/
export async function recoverFileSystem({
auth,
dependencies,
oldUsername,
newUsername,
readKey,
}: {
auth: AuthenticationStrategy
dependencies: {
crypto: Crypto.Implementation
reference: Reference.Implementation
storage: Storage.Implementation
}
} & RecoverFileSystemParams): Promise<{ success: boolean }> {
const { crypto, reference, storage } = dependencies
const newRootDID = await DID.agent(crypto)
// Register a new user with the `newUsername`
const { success } = await auth.register({
username: newUsername,
})
if (!success) {
throw new Error("Failed to register new user")
}
// Build an ephemeral UCAN to authorize the dataRoot.update call
const proof: string | null = await storage.getItem(storage.KEYS.ACCOUNT_UCAN)
const ucan = await Ucan.build({
dependencies,
potency: "APPEND",
resource: "*",
proof: proof ? proof : undefined,
lifetimeInSeconds: 60 * 3, // Three minutes
audience: newRootDID,
issuer: newRootDID,
})
const oldRootCID = await reference.dataRoot.lookup(oldUsername)
if (!oldRootCID) {
throw new Error("Failed to lookup oldUsername")
}
// Update the dataRoot of the new user
await reference.dataRoot.update(oldRootCID, ucan)
// Store the read key, which is namespaced using the account DID
await RootKey.store({
accountDID: newRootDID,
crypto: crypto,
readKey,
})
return {
success: true,
}
}
// VERSIONING
const DEFAULT_USER_MESSAGES = {
versionMismatch: {
newer: async () => alertIfPossible(`Sorry, we can't sync your filesystem with this app. This app only understands older versions of filesystems.\n\nPlease try to hard refresh this site or let this app's developer know.\n\nFeel free to contact Fission support: support@fission.codes`),
older: async () => alertIfPossible(`Sorry, we can't sync your filesystem with this app. Your filesystem version is out-dated and it needs to be migrated.\n\nRun a migration (https://guide.fission.codes/accounts/account-signup/account-migration) or talk to Fission support: support@fission.codes`),
}
}
export async function checkFileSystemVersion(
depot: Depot.Implementation,
config: Configuration,
filesystemCID: CID
): Promise<void> {
const links = await Protocol.basic.getSimpleLinks(depot, filesystemCID)
// if there's no version link, we assume it's from a 1.0.0-compatible version
// (from before ~ November 2020)
const versionStr = links[ RootBranch.Version ] == null
? "1.0.0"
: new TextDecoder().decode(
await Protocol.basic.getFile(
depot,
decodeCID(links[ RootBranch.Version ].cid)
)
)
const errorVersionBigger = async () => {
await (config.userMessages || DEFAULT_USER_MESSAGES).versionMismatch.newer(versionStr)
return new Error(`Incompatible filesystem version. Version: ${versionStr} Supported versions: ${Versions.supported.map(v => Versions.toString(v)).join(", ")}. Please upgrade this app's ODD SDK version.`)
}
const errorVersionSmaller = async () => {
await (config.userMessages || DEFAULT_USER_MESSAGES).versionMismatch.older(versionStr)
return new Error(`Incompatible filesystem version. Version: ${versionStr} Supported versions: ${Versions.supported.map(v => Versions.toString(v)).join(", ")}. The user should migrate their filesystem.`)
}
const versionParsed = Versions.fromString(versionStr)
if (versionParsed == null) {
throw await errorVersionBigger()
}
const support = Versions.isSupported(versionParsed)
if (support === "too-high") {
throw await errorVersionBigger()
}
if (support === "too-low") {
throw await errorVersionSmaller()
}
}
function alertIfPossible(str: string) {
if (globalThis.alert != null) globalThis.alert(str)
}
// ROOT HELPERS
/**
* Get a user's data root
*
* @param username The user's name
* @param options Optional parameters
* @param options.maxRetries Maximum number of retry attempts
* @param options.retryInterval Retry interval in milliseconds
* @returns data root CID or null
*/
async function getDataRoot(
reference: Reference.Implementation,
username: string,
options: { maxRetries?: number; retryInterval?: number }
= {}
): Promise<CID | null> {
const maxRetries = options.maxRetries ?? 0
const retryInterval = options.retryInterval ?? 500
let dataCid = await reference.dataRoot.lookup(username).catch(() => null)
if (dataCid) return (dataCid.toString() === EMPTY_CID ? null : dataCid)
return new Promise((resolve, reject) => {
let attempt = 0
const dataRootInterval = setInterval(async () => {
dataCid = await reference.dataRoot.lookup(username).catch(() => null)
if (!dataCid && attempt < maxRetries) {
attempt++
return
} else if (attempt >= maxRetries) {
reject("Failed to load data root")
}
clearInterval(dataRootInterval)
resolve(dataCid?.toString() === EMPTY_CID ? null : dataCid)
}, retryInterval)
})
}