UNPKG

12.3 kBPlain TextView Raw
1import { throttle } from 'throttle-debounce'
2
3import { BaseLinks } from './types'
4import { Branch, DistinctivePath, DirectoryPath, FilePath, Path } from '../path'
5import { PublishHook, UnixTree, Tree, File } from './types'
6import { SemVer } from './semver'
7import BareTree from './bare/tree'
8import RootTree from './root/tree'
9import PublicTree from './v1/PublicTree'
10import PrivateFile from './v1/PrivateFile'
11import PrivateTree from './v1/PrivateTree'
12
13import * as cidLog from '../common/cid-log'
14import * as dataRoot from '../data-root'
15import * as debug from '../common/debug'
16import * as crypto from '../crypto/index'
17import * as pathing from '../path'
18import * as typeCheck from './types/check'
19import * as ucan from '../ucan/index'
20
21import { CID, FileContent } from '../ipfs/index'
22import { NoPermissionError } from '../errors'
23import { Permissions, appDataPath } from '../ucan/permissions'
24
25
26// TYPES
27
28
29type AppPath =
30 (path?: DistinctivePath) => DistinctivePath
31
32type ConstructorParams = {
33 localOnly?: boolean
34 permissions?: Permissions
35 root: RootTree
36}
37
38type FileSystemOptions = {
39 localOnly?: boolean
40 permissions?: Permissions
41 version?: SemVer
42}
43
44type NewFileSystemOptions = FileSystemOptions & {
45 rootKey?: string
46}
47
48type MutationOptions = {
49 publish?: boolean
50}
51
52
53// CLASS
54
55
56export class FileSystem {
57
58 root: RootTree
59 readonly localOnly: boolean
60
61 appPath: AppPath | undefined
62 proofs: { [_: string]: string }
63 publishHooks: Array<PublishHook>
64
65 _publishWhenOnline: Array<[CID, string]>
66 _publishing: false | [CID, true]
67
68
69 constructor({ root, permissions, localOnly }: ConstructorParams) {
70 this.localOnly = localOnly || false
71 this.proofs = {}
72 this.publishHooks = []
73 this.root = root
74
75 this._publishWhenOnline = []
76 this._publishing = false
77
78 this._whenOnline = this._whenOnline.bind(this)
79 this._beforeLeaving = this._beforeLeaving.bind(this)
80
81 const globe = (globalThis as any)
82 globe.filesystems = globe.filesystems || []
83 globe.filesystems.push(this)
84
85 if (
86 permissions &&
87 permissions.app &&
88 permissions.app.creator &&
89 permissions.app.name
90 ) {
91 this.appPath = appPath(permissions)
92 }
93
94 // Add the root CID of the file system to the CID log
95 // (reverse list, newest cid first)
96 const logCid = (cid: CID) => {
97 cidLog.add(cid)
98 debug.log("📓 Adding to the CID ledger:", cid)
99 }
100
101 // Update the user's data root when making changes
102 const updateDataRootWhenOnline = throttle(3000, false, (cid, proof) => {
103 if (globalThis.navigator.onLine) {
104 this._publishing = [cid, true]
105 return dataRoot.update(cid, proof).then(() => {
106 if (this._publishing && this._publishing[0] === cid) {
107 this._publishing = false
108 }
109 })
110 }
111
112 this._publishWhenOnline.push([ cid, proof ])
113 }, false)
114
115 this.publishHooks.push(logCid)
116 this.publishHooks.push(updateDataRootWhenOnline)
117
118 if (!this.localOnly) {
119 // Publish when coming back online
120 globalThis.addEventListener('online', this._whenOnline)
121
122 // Show an alert when leaving the page while updating the data root
123 globalThis.addEventListener('beforeunload', this._beforeLeaving)
124 }
125 }
126
127
128 // INITIALISATION
129 // --------------
130
131 /**
132 * Creates a file system with an empty public tree & an empty private tree at the root.
133 */
134 static async empty(opts: NewFileSystemOptions = {}): Promise<FileSystem> {
135 const { permissions, localOnly } = opts
136 const rootKey = opts.rootKey || await crypto.aes.genKeyStr()
137 const root = await RootTree.empty({ rootKey })
138
139 const fs = new FileSystem({
140 root,
141 permissions,
142 localOnly
143 })
144
145 return fs
146 }
147
148 /**
149 * Loads an existing file system from a CID.
150 */
151 static async fromCID(cid: CID, opts: FileSystemOptions = {}): Promise<FileSystem | null> {
152 const { permissions, localOnly } = opts
153 const root = await RootTree.fromCID({ cid, permissions })
154
155 const fs = new FileSystem({
156 root,
157 permissions,
158 localOnly
159 })
160
161 return fs
162 }
163
164
165 // DEACTIVATE
166 // ----------
167
168 /**
169 * Deactivate a file system.
170 *
171 * Use this when a user signs out.
172 * The only function of this is to stop listing to online/offline events.
173 */
174 deactivate(): void {
175 if (this.localOnly) return
176 const globe = (globalThis as any)
177 globe.filesystems = globe.filesystems.filter((a: FileSystem) => a !== this)
178 globe.removeEventListener('online', this._whenOnline)
179 globe.removeEventListener('beforeunload', this._beforeLeaving)
180 }
181
182
183 // POSIX INTERFACE (DIRECTORIES)
184 // -----------------------------
185
186 async ls(path: DirectoryPath): Promise<BaseLinks> {
187 if (pathing.isFile(path)) throw new Error("`ls` only accepts directory paths")
188 return this.runOnNode(path, false, (node, relPath) => {
189 if (typeCheck.isFile(node)) {
190 throw new Error("Tried to `ls` a file")
191 } else {
192 return node.ls(relPath)
193 }
194 })
195 }
196
197 async mkdir(path: DirectoryPath, options: MutationOptions = {}): Promise<this> {
198 if (pathing.isFile(path)) throw new Error("`mkdir` only accepts directory paths")
199 await this.runOnNode(path, true, (node, relPath) => {
200 if (typeCheck.isFile(node)) {
201 throw new Error("Tried to `mkdir` a file")
202 } else {
203 return node.mkdir(relPath)
204 }
205 })
206 if (options.publish) {
207 await this.publish()
208 }
209 return this
210 }
211
212 // POSIX INTERFACE (FILES)
213 // -----------------------
214
215 async add(path: FilePath, content: FileContent, options: MutationOptions = {}): Promise<this> {
216 if (pathing.isDirectory(path)) throw new Error("`add` only accepts file paths")
217 await this.runOnNode(path, true, async (node, relPath) => {
218 return typeCheck.isFile(node)
219 ? node.updateContent(content)
220 : node.add(relPath, content)
221 })
222 if (options.publish) {
223 await this.publish()
224 }
225 return this
226 }
227
228 async cat(path: FilePath): Promise<FileContent> {
229 if (pathing.isDirectory(path)) throw new Error("`cat` only accepts file paths")
230 return this.runOnNode(path, false, async (node, relPath) => {
231 return typeCheck.isFile(node)
232 ? node.content
233 : node.cat(relPath)
234 })
235 }
236
237 async read(path: FilePath): Promise<FileContent | null> {
238 if (pathing.isDirectory(path)) throw new Error("`read` only accepts file paths")
239 return this.cat(path)
240 }
241
242 async write(path: FilePath, content: FileContent, options: MutationOptions = {}): Promise<this> {
243 if (pathing.isDirectory(path)) throw new Error("`write` only accepts file paths")
244 return this.add(path, content, options)
245 }
246
247 // POSIX INTERFACE (GENERAL)
248 // -------------------------
249
250 async exists(path: DistinctivePath): Promise<boolean> {
251 return this.runOnNode(path, false, async (node, relPath) => {
252 return typeCheck.isFile(node)
253 ? true // tried to check the existance of itself
254 : node.exists(relPath)
255 })
256 }
257
258 async get(path: DistinctivePath): Promise<Tree | File | null> {
259 return this.runOnNode(path, false, async (node, relPath) => {
260 return typeCheck.isFile(node)
261 ? node // tried to get itself
262 : node.get(relPath)
263 })
264 }
265
266 // This is only implemented on the same tree for now and will error otherwise
267 async mv(from: DistinctivePath, to: DistinctivePath): Promise<this> {
268 const sameTree = pathing.isSameBranch(from, to)
269
270 if (!pathing.isSameKind(from, to)) {
271 const kindFrom = pathing.kind(from)
272 const kindTo = pathing.kind(to)
273 throw new Error(`Can't move to a different kind of path, from is a ${kindFrom} and to is a ${kindTo}`)
274 }
275
276 if (!sameTree) {
277 throw new Error("`mv` is only supported on the same tree for now")
278 }
279
280 if (await this.exists(to)) {
281 throw new Error("Destination already exists")
282 }
283
284 await this.runOnNode(from, true, (node, relPath) => {
285 if (typeCheck.isFile(node)) {
286 throw new Error("Tried to `mv` within a file")
287 }
288
289 const [ head, ...nextPath ] = pathing.unwrap(to)
290 return node.mv(relPath, nextPath)
291 })
292
293 return this
294 }
295
296 async rm(path: DistinctivePath): Promise<this> {
297 await this.runOnNode(path, true, (node, relPath) => {
298 if (typeCheck.isFile(node)) {
299 throw new Error("Cannot `rm` a file you've asked permission for")
300 } else {
301 return node.rm(relPath)
302 }
303 })
304
305 return this
306 }
307
308
309 // PUBLISH
310 // -------
311
312 /**
313 * Ensures the latest version of the file system is added to IPFS,
314 * updates your data root, and returns the root CID.
315 */
316 async publish(): Promise<CID> {
317 const proofs = Array.from(Object.entries(this.proofs))
318 this.proofs = {}
319
320 const cid = await this.root.put()
321
322 proofs.forEach(([_, proof]) => {
323 this.publishHooks.forEach(hook => hook(cid, proof))
324 })
325
326 return cid
327 }
328
329
330
331 // INTERNAL
332 // --------
333
334 /** @internal */
335 async runOnNode<a>(
336 path: DistinctivePath,
337 isMutation: boolean,
338 fn: (node: UnixTree | File, relPath: Path) => Promise<a>
339 ): Promise<a> {
340 const parts = pathing.unwrap(path)
341 const head = parts[0]
342 const relPath = parts.slice(1)
343
344 const operation = isMutation
345 ? "make changes to"
346 : "query"
347
348 if (!this.localOnly) {
349 const proof = await ucan.dictionary.lookupFilesystemUcan(path)
350 const decodedProof = proof && ucan.decode(proof)
351
352 if (!proof || !decodedProof || ucan.isExpired(decodedProof) || !decodedProof.signature) {
353 throw new NoPermissionError(`I don't have the necessary permissions to ${operation} the file system at "${pathing.toPosix(path)}"`)
354 }
355
356 this.proofs[decodedProof.signature] = proof
357 }
358
359 let result: a
360 let resultPretty: a
361
362 if (head === Branch.Public) {
363 result = await fn(this.root.publicTree, relPath)
364
365 if (isMutation && PublicTree.instanceOf(result)) {
366 resultPretty = await fn(this.root.prettyTree, relPath)
367
368 this.root.publicTree = result
369 this.root.prettyTree = resultPretty as unknown as BareTree
370
371 await Promise.all([
372 this.root.updatePuttable(Branch.Public, this.root.publicTree),
373 this.root.updatePuttable(Branch.Pretty, this.root.prettyTree)
374 ])
375 }
376
377 } else if (head === Branch.Private) {
378 const [nodePath, node] = this.root.findPrivateNode(
379 path
380 )
381
382 if (!node) {
383 throw new NoPermissionError(`I don't have the necessary permissions to ${operation} the file system at "${pathing.toPosix(path)}"`)
384 }
385
386 result = await fn(
387 node,
388 parts.slice(pathing.unwrap(nodePath).length)
389 )
390
391 if (
392 isMutation &&
393 (PrivateTree.instanceOf(result) || PrivateFile.instanceOf(result))
394 ) {
395 this.root.privateNodes[pathing.toPosix(nodePath)] = result
396 await result.put()
397 await this.root.updatePuttable(Branch.Private, this.root.mmpt)
398
399 const cid = await this.root.mmpt.put()
400 await this.root.addPrivateLogEntry(cid)
401 }
402
403 } else if (head === Branch.Pretty && isMutation) {
404 throw new Error("The pretty path is read only")
405
406 } else if (head === Branch.Pretty) {
407 result = await fn(this.root.prettyTree, relPath)
408
409 } else {
410 throw new Error("Not a valid FileSystem path")
411
412 }
413
414 return result
415 }
416
417 /** @internal */
418 _whenOnline(): void {
419 const toPublish = [...this._publishWhenOnline]
420 this._publishWhenOnline = []
421
422 toPublish.forEach(([cid, proof]) => {
423 this.publishHooks.forEach(hook => hook(cid, proof))
424 })
425 }
426
427 /** @internal */
428 _beforeLeaving(e: Event): void | string {
429 const msg = "Are you sure you want to leave? We don't control the browser so you may lose your data."
430
431 if (this._publishing || this._publishWhenOnline.length) {
432 (e || globalThis.event).returnValue = msg as any
433 return msg
434 }
435 }
436}
437
438
439export default FileSystem
440
441
442
443// ㊙️
444
445
446function appPath(permissions: Permissions): AppPath {
447 if (!permissions.app) throw Error("Only works with app permissions")
448 const base = appDataPath(permissions.app)
449
450 return path => {
451 if (path) return pathing.combine(base, path)
452 return base
453 }
454}