1 | import { throttle } from 'throttle-debounce'
|
2 |
|
3 | import { BaseLinks } from './types'
|
4 | import { Branch, DistinctivePath, DirectoryPath, FilePath, Path } from '../path'
|
5 | import { PublishHook, UnixTree, Tree, File } from './types'
|
6 | import { SemVer } from './semver'
|
7 | import BareTree from './bare/tree'
|
8 | import RootTree from './root/tree'
|
9 | import PublicTree from './v1/PublicTree'
|
10 | import PrivateFile from './v1/PrivateFile'
|
11 | import PrivateTree from './v1/PrivateTree'
|
12 |
|
13 | import * as cidLog from '../common/cid-log'
|
14 | import * as dataRoot from '../data-root'
|
15 | import * as debug from '../common/debug'
|
16 | import * as crypto from '../crypto/index'
|
17 | import * as pathing from '../path'
|
18 | import * as typeCheck from './types/check'
|
19 | import * as ucan from '../ucan/index'
|
20 |
|
21 | import { CID, FileContent } from '../ipfs/index'
|
22 | import { NoPermissionError } from '../errors'
|
23 | import { Permissions, appDataPath } from '../ucan/permissions'
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 | type AppPath =
|
30 | (path?: DistinctivePath) => DistinctivePath
|
31 |
|
32 | type ConstructorParams = {
|
33 | localOnly?: boolean
|
34 | permissions?: Permissions
|
35 | root: RootTree
|
36 | }
|
37 |
|
38 | type FileSystemOptions = {
|
39 | localOnly?: boolean
|
40 | permissions?: Permissions
|
41 | version?: SemVer
|
42 | }
|
43 |
|
44 | type NewFileSystemOptions = FileSystemOptions & {
|
45 | rootKey?: string
|
46 | }
|
47 |
|
48 | type MutationOptions = {
|
49 | publish?: boolean
|
50 | }
|
51 |
|
52 |
|
53 |
|
54 |
|
55 |
|
56 | export 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 |
|
95 |
|
96 | const logCid = (cid: CID) => {
|
97 | cidLog.add(cid)
|
98 | debug.log("📓 Adding to the CID ledger:", cid)
|
99 | }
|
100 |
|
101 |
|
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 |
|
120 | globalThis.addEventListener('online', this._whenOnline)
|
121 |
|
122 |
|
123 | globalThis.addEventListener('beforeunload', this._beforeLeaving)
|
124 | }
|
125 | }
|
126 |
|
127 |
|
128 |
|
129 |
|
130 |
|
131 | |
132 |
|
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 |
|
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 |
|
166 |
|
167 |
|
168 | |
169 |
|
170 |
|
171 |
|
172 |
|
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 |
|
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 |
|
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 |
|
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
|
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
|
262 | : node.get(relPath)
|
263 | })
|
264 | }
|
265 |
|
266 |
|
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 |
|
310 |
|
311 |
|
312 | |
313 |
|
314 |
|
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 |
|
332 |
|
333 |
|
334 |
|
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 |
|
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 |
|
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 |
|
439 | export default FileSystem
|
440 |
|
441 |
|
442 |
|
443 |
|
444 |
|
445 |
|
446 | function 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 | }
|