hfs 0.26.7 → 0.26.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/admin/.DS_Store +0 -0
  2. package/admin/.eslintrc +8 -0
  3. package/admin/.gitignore +23 -0
  4. package/admin/index.html +1 -3
  5. package/admin/package.json +67 -0
  6. package/admin/{logo.svg → public/logo.svg} +0 -0
  7. package/admin/src/AccountForm.ts +92 -0
  8. package/admin/src/AccountsPage.ts +143 -0
  9. package/admin/src/App.ts +83 -0
  10. package/admin/src/ArrayField.ts +84 -0
  11. package/admin/src/ConfigPage.ts +279 -0
  12. package/admin/src/FileField.ts +52 -0
  13. package/admin/src/FileForm.ts +148 -0
  14. package/admin/src/FilePicker.ts +166 -0
  15. package/admin/src/HomePage.ts +96 -0
  16. package/admin/src/InstalledPlugins.ts +158 -0
  17. package/admin/src/LoginRequired.ts +75 -0
  18. package/admin/src/LogoutPage.ts +27 -0
  19. package/admin/src/LogsPage.ts +75 -0
  20. package/admin/src/MainMenu.ts +74 -0
  21. package/admin/src/MenuButton.ts +38 -0
  22. package/admin/src/MonitorPage.ts +200 -0
  23. package/admin/src/OnlinePlugins.ts +101 -0
  24. package/admin/src/PermField.ts +80 -0
  25. package/admin/src/PluginsPage.ts +27 -0
  26. package/admin/src/VfsMenuBar.ts +58 -0
  27. package/admin/src/VfsPage.ts +124 -0
  28. package/admin/src/VfsTree.ts +95 -0
  29. package/admin/src/addFiles.ts +59 -0
  30. package/admin/src/api.ts +246 -0
  31. package/admin/src/dialog.ts +203 -0
  32. package/admin/src/index.css +21 -0
  33. package/admin/src/index.ts +10 -0
  34. package/admin/src/md.ts +31 -0
  35. package/admin/src/misc.ts +141 -0
  36. package/admin/src/react-app-env.d.ts +1 -0
  37. package/admin/src/reportWebVitals.ts +15 -0
  38. package/admin/src/setupTests.ts +5 -0
  39. package/admin/src/state.ts +40 -0
  40. package/admin/src/theme.ts +37 -0
  41. package/admin/tsconfig.json +26 -0
  42. package/admin/vite.config.ts +32 -0
  43. package/frontend/.DS_Store +0 -0
  44. package/frontend/.eslintrc +8 -0
  45. package/frontend/.gitignore +23 -0
  46. package/frontend/index.html +1 -3
  47. package/frontend/package.json +51 -0
  48. package/frontend/{fontello.css → public/fontello.css} +0 -0
  49. package/frontend/{fontello.woff2 → public/fontello.woff2} +0 -0
  50. package/frontend/src/App.ts +25 -0
  51. package/frontend/src/Breadcrumbs.ts +43 -0
  52. package/frontend/src/BrowseFiles.ts +141 -0
  53. package/frontend/src/Head.ts +45 -0
  54. package/frontend/src/UserPanel.ts +52 -0
  55. package/frontend/src/api.ts +78 -0
  56. package/frontend/src/components.ts +54 -0
  57. package/frontend/src/dialog.css +76 -0
  58. package/frontend/src/dialog.ts +105 -0
  59. package/frontend/src/icons.ts +46 -0
  60. package/frontend/src/index.scss +307 -0
  61. package/frontend/src/index.ts +10 -0
  62. package/frontend/src/login.ts +50 -0
  63. package/frontend/src/menu.ts +188 -0
  64. package/frontend/src/misc.ts +54 -0
  65. package/frontend/src/options.ts +52 -0
  66. package/frontend/src/react-app-env.d.ts +1 -0
  67. package/frontend/src/reportWebVitals.ts +15 -0
  68. package/frontend/src/setupTests.ts +5 -0
  69. package/frontend/src/state.ts +82 -0
  70. package/frontend/src/useAuthorized.ts +17 -0
  71. package/frontend/src/useFetchList.ts +144 -0
  72. package/frontend/src/useTheme.ts +23 -0
  73. package/frontend/tsconfig.json +26 -0
  74. package/frontend/vite.config.ts +21 -0
  75. package/package.json +2 -1
  76. package/plugins/vhosting/plugin.js +1 -1
  77. package/src/QuickZipStream.ts +279 -0
  78. package/src/ThrottledStream.ts +98 -0
  79. package/src/adminApis.ts +161 -0
  80. package/src/api.accounts.ts +78 -0
  81. package/src/api.auth.ts +131 -0
  82. package/src/api.file_list.ts +102 -0
  83. package/src/api.helpers.ts +30 -0
  84. package/src/api.monitor.ts +106 -0
  85. package/src/api.plugins.ts +139 -0
  86. package/src/api.vfs.ts +182 -0
  87. package/src/apiMiddleware.ts +124 -0
  88. package/src/block.ts +35 -0
  89. package/src/commands.ts +122 -0
  90. package/src/config.ts +166 -0
  91. package/src/connections.ts +60 -0
  92. package/src/const.ts +57 -0
  93. package/src/crypt.ts +16 -0
  94. package/src/debounceAsync.ts +51 -0
  95. package/src/events.ts +6 -0
  96. package/src/frontEndApis.ts +17 -0
  97. package/src/github.ts +102 -0
  98. package/src/index.ts +53 -0
  99. package/src/listen.ts +220 -0
  100. package/src/log.ts +128 -0
  101. package/src/middlewares.ts +176 -0
  102. package/src/misc.ts +149 -0
  103. package/src/pbkdf2.ts +83 -0
  104. package/src/perm.ts +194 -0
  105. package/src/plugins.ts +342 -0
  106. package/src/serveFile.ts +104 -0
  107. package/src/serveGuiFiles.ts +95 -0
  108. package/src/sse.ts +29 -0
  109. package/src/throttler.ts +106 -0
  110. package/src/update.ts +67 -0
  111. package/src/util-files.ts +137 -0
  112. package/src/util-generators.ts +29 -0
  113. package/src/util-http.ts +29 -0
  114. package/src/vfs.ts +258 -0
  115. package/src/watchLoad.ts +75 -0
  116. package/src/zip.ts +69 -0
  117. package/admin/assets/index.0f549e00.js +0 -281
  118. package/admin/assets/index.dcc78777.css +0 -1
  119. package/admin/assets/sha512.ea1121b3.js +0 -8
  120. package/frontend/assets/index.1151988f.js +0 -85
  121. package/frontend/assets/index.93366732.css +0 -1
  122. package/frontend/assets/sha512.bb881250.js +0 -8
  123. package/src/QuickZipStream.js +0 -285
  124. package/src/ThrottledStream.js +0 -93
  125. package/src/adminApis.js +0 -169
  126. package/src/api.accounts.js +0 -59
  127. package/src/api.auth.js +0 -130
  128. package/src/api.file_list.js +0 -103
  129. package/src/api.helpers.js +0 -32
  130. package/src/api.monitor.js +0 -102
  131. package/src/api.plugins.js +0 -127
  132. package/src/api.vfs.js +0 -164
  133. package/src/apiMiddleware.js +0 -136
  134. package/src/block.js +0 -33
  135. package/src/commands.js +0 -124
  136. package/src/config.js +0 -168
  137. package/src/connections.js +0 -57
  138. package/src/const.js +0 -83
  139. package/src/crypt.js +0 -21
  140. package/src/debounceAsync.js +0 -48
  141. package/src/events.js +0 -9
  142. package/src/frontEndApis.js +0 -38
  143. package/src/github.js +0 -102
  144. package/src/index.js +0 -55
  145. package/src/listen.js +0 -235
  146. package/src/log.js +0 -137
  147. package/src/middlewares.js +0 -154
  148. package/src/misc.js +0 -160
  149. package/src/pbkdf2.js +0 -74
  150. package/src/perm.js +0 -176
  151. package/src/plugins.js +0 -343
  152. package/src/serveFile.js +0 -104
  153. package/src/serveGuiFiles.js +0 -113
  154. package/src/sse.js +0 -29
  155. package/src/throttler.js +0 -91
  156. package/src/update.js +0 -69
  157. package/src/util-files.js +0 -148
  158. package/src/util-generators.js +0 -30
  159. package/src/util-http.js +0 -30
  160. package/src/vfs.js +0 -227
  161. package/src/watchLoad.js +0 -73
  162. package/src/zip.js +0 -69
@@ -0,0 +1,104 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import Koa from 'koa'
4
+ import { createReadStream, stat } from 'fs'
5
+ import { FORBIDDEN, METHOD_NOT_ALLOWED, NO_CONTENT } from './const'
6
+ import { getNodeName, MIME_AUTO, VfsNode } from './vfs'
7
+ import mimetypes from 'mime-types'
8
+ import { defineConfig } from './config'
9
+ import { isMatch } from 'micromatch'
10
+ import _ from 'lodash'
11
+ import path from 'path'
12
+ import { promisify } from 'util'
13
+
14
+ const allowedReferer = defineConfig('allowed_referer', '')
15
+
16
+ export function serveFileNode(node: VfsNode) : Koa.Middleware {
17
+ const { source, mime } = node
18
+ const name = getNodeName(node)
19
+ const mimeString = typeof mime === 'string' ? mime
20
+ : _.find(mime, (val,mask) => isMatch(name, mask))
21
+ return (ctx, next) => {
22
+ const allowed = allowedReferer.get()
23
+ if (allowed) {
24
+ const ref = /\/\/([^:/]+)/.exec(ctx.get('referer'))?.[1] // extract host from url
25
+ if (ref && ref !== host() // automatic accept if referer is basically the hosting domain
26
+ && !isMatch(ref, allowed))
27
+ return ctx.status = FORBIDDEN
28
+
29
+ function host() {
30
+ const s = ctx.get('host')
31
+ return s[0] === '[' ? s.slice(1, s.indexOf(']')) : s?.split(':')[0]
32
+ }
33
+ }
34
+
35
+ ctx.vfsNode = node // useful to tell service files from files shared by the user
36
+ return serveFile(source||'', mimeString)(ctx, next)
37
+ }
38
+ }
39
+
40
+ const mimeCfg = defineConfig<Record<string,string>>('mime', { '*': 'auto' })
41
+
42
+ export function serveFile(source:string, mime?:string, content?: string | Buffer) : Koa.Middleware {
43
+ return async (ctx) => {
44
+ if (!source)
45
+ return
46
+ const fn = path.basename(source)
47
+ mime = mime ?? _.find(mimeCfg.get(), (v,k) => k>'' && isMatch(fn, k)) // isMatch throws on an empty string
48
+ if (mime === MIME_AUTO)
49
+ mime = mimetypes.lookup(source) || ''
50
+ if (mime)
51
+ ctx.type = mime
52
+ if (ctx.method === 'OPTIONS') {
53
+ ctx.status = NO_CONTENT
54
+ ctx.set({ Allow: 'OPTIONS, GET, HEAD' })
55
+ return
56
+ }
57
+ if (ctx.method !== 'GET')
58
+ return ctx.status = METHOD_NOT_ALLOWED
59
+ try {
60
+ const stats = await promisify(stat)(source) // using fs's function instead of fs/promises, because only the former is supported by pkg
61
+ ctx.set('Last-Modified', stats.mtime.toUTCString())
62
+ ctx.fileSource = source
63
+ ctx.status = 200
64
+ if (ctx.fresh)
65
+ return ctx.status = 304
66
+ if (content !== undefined)
67
+ return ctx.body = content
68
+ const range = getRange(ctx, stats.size)
69
+ ctx.body = createReadStream(source, range)
70
+ }
71
+ catch {
72
+ return ctx.status = 404
73
+ }
74
+ }
75
+ }
76
+
77
+ export function getRange(ctx: Koa.Context, totalSize: number) {
78
+ ctx.set('Accept-Ranges', 'bytes')
79
+ const { range } = ctx.request.header
80
+ if (!range) {
81
+ ctx.response.length = totalSize
82
+ return
83
+ }
84
+ const ranges = range.split('=')[1]
85
+ if (ranges.includes(','))
86
+ return ctx.throw(400, 'multi-range not supported')
87
+ let bytes = ranges?.split('-')
88
+ if (!bytes?.length)
89
+ return ctx.throw(400, 'bad range')
90
+ const max = totalSize - 1
91
+ const start = bytes[0] ? Number(bytes[0]) : Math.max(0, totalSize-Number(bytes[1])) // a negative start is relative to the end
92
+ const end = bytes[0] ? Number(bytes[1] || max) : max
93
+ // we don't support last-bytes without knowing max
94
+ if (isNaN(end) && isNaN(max) || end > max || start > max) {
95
+ ctx.status = 416
96
+ ctx.set('Content-Range', `bytes ${totalSize}`)
97
+ ctx.body = 'Requested Range Not Satisfiable'
98
+ return
99
+ }
100
+ ctx.status = 206
101
+ ctx.set('Content-Range', `bytes ${start}-${isNaN(end) ? '' : end}/${isNaN(totalSize) ? '*' : totalSize}`)
102
+ ctx.response.length = end - start + 1
103
+ return { start, end }
104
+ }
@@ -0,0 +1,95 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import Koa from 'koa'
4
+ import fs from 'fs/promises'
5
+ import { METHOD_NOT_ALLOWED, NO_CONTENT, PLUGINS_PUB_URI } from './const'
6
+ import { serveFile } from './serveFile'
7
+ import { mapPlugins } from './plugins'
8
+ import { refresh_session } from './api.auth'
9
+ import { ApiError } from './apiMiddleware'
10
+ import { join, extname } from 'path'
11
+ import { getOrSet } from './misc'
12
+
13
+ // in case of dev env we have our static files within the 'dist' folder'
14
+ const DEV_STATIC = process.env.DEV ? 'dist/' : ''
15
+
16
+ function serveStatic(uri: string): Koa.Middleware {
17
+ const folder = uri.slice(2,-1) // we know folder is very similar to uri
18
+ const cache: Record<string, Promise<string>> = {}
19
+ return async (ctx, next) => {
20
+ if(ctx.method === 'OPTIONS') {
21
+ ctx.status = NO_CONTENT
22
+ ctx.set({ Allow: 'OPTIONS, GET' })
23
+ return
24
+ }
25
+ if (ctx.method !== 'GET')
26
+ return ctx.status = METHOD_NOT_ALLOWED
27
+ const serveApp = shouldServeApp(ctx)
28
+ const fullPath = join(__dirname, '..', DEV_STATIC, folder, serveApp ? '/index.html': ctx.path)
29
+ const content = await getOrSet(cache, ctx.path, async () => {
30
+ const data = await fs.readFile(fullPath).catch(() => null)
31
+ return serveApp || !data ? data
32
+ : adjustBundlerLinks(ctx.path, uri, data)
33
+ })
34
+ if (content === null)
35
+ return ctx.status = 404
36
+ if (!serveApp)
37
+ return serveFile(fullPath, 'auto', content)(ctx, next)
38
+ // we don't cache the index as it's small and may prevent plugins change to apply
39
+ ctx.body = await treatIndex(ctx, String(content), uri)
40
+ ctx.type = 'html'
41
+ ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
42
+ }
43
+ }
44
+
45
+ function shouldServeApp(ctx: Koa.Context) {
46
+ return ctx.state.serveApp ||= ctx.path.endsWith('/')
47
+ }
48
+
49
+ function adjustBundlerLinks(path: string, uri: string, data: string | Buffer) {
50
+ const ext = extname(path)
51
+ return ext && !ext.match(/\.(css|html|js|ts|scss)/) ? data
52
+ : String(data).replace(/((?:import | from )['"])\//g, `$1${uri}`)
53
+ }
54
+
55
+ async function treatIndex(ctx: Koa.Context, body: string, filesUri: string) {
56
+ const session = await refresh_session({}, ctx)
57
+ ctx.set('etag', '')
58
+ return body
59
+ .replace(/((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + filesUri)
60
+ .replace('_HFS_SESSION_', session instanceof ApiError ? 'null' : JSON.stringify(session))
61
+ // replacing this text allow us to avoid injecting in frontends that don't support plugins. Don't use a <--comment--> or it will be removed by webpack
62
+ .replace('_HFS_PLUGINS_', pluginsInjection)
63
+ }
64
+
65
+ function serveProxied(port: string | undefined, uri: string) { // used for development only
66
+ if (!port)
67
+ return
68
+ console.debug('proxied on port', port)
69
+ let proxy: Koa.Middleware
70
+ import('koa-better-http-proxy').then(lib => // dynamic import to avoid having this in final distribution
71
+ proxy = lib.default('127.0.0.1:'+port, {
72
+ proxyReqPathResolver: (ctx) =>
73
+ shouldServeApp(ctx) ? '/' : ctx.path,
74
+ userResDecorator(res, data, ctx) {
75
+ return shouldServeApp(ctx) ? treatIndex(ctx, String(data), uri)
76
+ : adjustBundlerLinks(ctx.path, uri, data)
77
+ }
78
+ }) )
79
+ return function() { //@ts-ignore
80
+ return proxy.apply(this,arguments)
81
+ }
82
+ }
83
+
84
+ function pluginsInjection() {
85
+ const css = mapPlugins((plug,k) =>
86
+ plug.frontend_css?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
87
+ const js = mapPlugins((plug,k) =>
88
+ plug.frontend_js?.map(f => PLUGINS_PUB_URI + k + '/' + f)).flat().filter(Boolean)
89
+ return css.map(uri => `\n<link rel='stylesheet' type='text/css' href='${uri}'/>`).join('')
90
+ + js.map(uri => `\n<script defer src='${uri}'></script>`).join('')
91
+ }
92
+
93
+ export function serveGuiFiles(proxyPort:string | undefined, uri:string) {
94
+ return serveProxied(proxyPort, uri) || serveStatic(uri)
95
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,29 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import Koa from 'koa'
4
+ import { Transform } from 'stream'
5
+
6
+ export default function createSSE(ctx: Koa.Context) {
7
+ const { socket } = ctx.req
8
+ socket.setTimeout(0)
9
+ socket.setNoDelay(true)
10
+ socket.setKeepAlive(true)
11
+ ctx.set({
12
+ 'Content-Type': 'text/event-stream',
13
+ 'Cache-Control': 'no-cache',
14
+ 'Connection': 'keep-alive',
15
+ 'X-Accel-Buffering': 'no', // avoid buffering when reverse-proxied through nginx
16
+ })
17
+ ctx.status = 200
18
+ return ctx.body = new Transform({
19
+ objectMode: true,
20
+ transform(chunk, encoding, cb) {
21
+ this.push(`data: ${JSON.stringify(chunk)}\n\n`)
22
+ cb()
23
+ },
24
+ flush(cb) {
25
+ this.push('data:\n\n')
26
+ cb()
27
+ }
28
+ })
29
+ }
@@ -0,0 +1,106 @@
1
+ // This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
+
3
+ import { Readable } from 'stream'
4
+ import Koa from 'koa'
5
+ import { ThrottledStream, ThrottleGroup } from './ThrottledStream'
6
+ import { defineConfig } from './config'
7
+ import { getOrSet, isLocalHost } from './misc'
8
+ import { Connection, updateConnection } from './connections'
9
+ import _ from 'lodash'
10
+ import events from './events'
11
+
12
+ const mainThrottleGroup = new ThrottleGroup(Infinity)
13
+
14
+ defineConfig('max_kbps', Infinity).sub(v =>
15
+ mainThrottleGroup.updateLimit(v))
16
+
17
+ const ip2group: Record<string, {
18
+ count: number
19
+ group: ThrottleGroup
20
+ destroy: () => void
21
+ }> = {}
22
+
23
+ const SymThrStr = Symbol('stream')
24
+ const SymTimeout = Symbol('timeout')
25
+
26
+ const maxKbpsPerIp = defineConfig('max_kbps_per_ip', Infinity)
27
+
28
+ export const throttler: Koa.Middleware = async (ctx, next) => {
29
+ await next()
30
+ const { body } = ctx
31
+ if (!body || !(body instanceof Readable))
32
+ return
33
+ // we wrap the stream also for unlimited connections to get speed and other features
34
+ const ipGroup = getOrSet(ip2group, ctx.ip, ()=> {
35
+ const doLimit = ctx.state.account?.ignore_limits || isLocalHost(ctx) ? undefined : true
36
+ const group = new ThrottleGroup(Infinity, doLimit && mainThrottleGroup)
37
+
38
+ const unsub = doLimit && maxKbpsPerIp.sub(v =>
39
+ group.updateLimit(v))
40
+ return { group, count:0, destroy: unsub }
41
+ })
42
+ const conn = ctx.state.connection as Connection | undefined
43
+ if (!conn) throw 'assert throttler connection'
44
+
45
+ const ts = conn[SymThrStr] = new ThrottledStream(ipGroup.group, conn[SymThrStr])
46
+ let closed = false
47
+
48
+ const DELAY = 1000
49
+ const update = _.debounce(() => {
50
+ const ts = conn[SymThrStr] as ThrottledStream
51
+ const outSpeed = roundKb(ts.getSpeed())
52
+ updateConnection(conn, { outSpeed, sent: ts.getBytesSent() })
53
+ /* in case this stream stands still for a while (before the end), we'll have neither 'sent' or 'close' events,
54
+ * so who will take care to updateConnection? This artificial next-call will ensure just that */
55
+ clearTimeout(conn[SymTimeout])
56
+ if (outSpeed || !closed)
57
+ conn[SymTimeout] = setTimeout(update, DELAY)
58
+ }, DELAY, { maxWait:DELAY })
59
+ ts.on('sent', (n: number) => {
60
+ totalSent += n
61
+ update()
62
+ })
63
+
64
+ ++ipGroup.count
65
+ ts.on('close', ()=> {
66
+ update.flush()
67
+ closed = true
68
+ if (--ipGroup.count) return // any left?
69
+ ipGroup.destroy?.()
70
+ delete ip2group[ctx.ip]
71
+ })
72
+
73
+ const bak = ctx.response.length // preserve
74
+ ctx.body = ctx.body.pipe(ts)
75
+
76
+ if (bak)
77
+ ctx.response.length = bak
78
+ }
79
+
80
+ function roundKb(n: number) {
81
+ return _.round(n, 1) || _.round(n, 3) // further precision if necessary
82
+ }
83
+
84
+ export let totalSent = 0
85
+ export let totalGot = 0
86
+ export let totalOutSpeed = 0
87
+ export let totalInSpeed = 0
88
+
89
+ let lastSent = totalSent
90
+ let lastGot = totalGot
91
+ let last = Date.now()
92
+ setInterval(() => {
93
+ const now = Date.now()
94
+ const past = (now - last) / 1000 // seconds
95
+ last = now
96
+ const deltaSentKb = (totalSent - lastSent) / 1000
97
+ lastSent = totalSent
98
+ const deltaGotKb = (totalGot - lastGot) / 1000
99
+ lastGot = totalGot
100
+ totalOutSpeed = roundKb(deltaSentKb / past)
101
+ totalInSpeed = roundKb(deltaGotKb / past)
102
+ }, 1000)
103
+
104
+ events.on('connection', (c: Connection) =>
105
+ c.socket.on('data', data =>
106
+ totalGot += data.length ))
package/src/update.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { getRepoInfo } from './github'
2
+ import { argv, IS_WINDOWS, VERSION } from './const'
3
+ import { basename, dirname, join } from 'path'
4
+ import { spawn } from 'child_process'
5
+ import { httpsStream, onProcessExit, unzip } from './misc'
6
+ import { renameSync, unlinkSync } from 'fs'
7
+ import { pluginsWatcher } from './plugins'
8
+ import { chmod, stat } from 'fs/promises'
9
+
10
+ const HFS_REPO = 'rejetto/hfs'
11
+
12
+ export async function getUpdate() {
13
+ const [latest] = await getRepoInfo(HFS_REPO + '/releases?per_page=1')
14
+ if (latest.name === VERSION)
15
+ throw "you already have the latest version: " + VERSION
16
+ return latest
17
+ }
18
+
19
+ export async function update() {
20
+ if (process.argv0.endsWith('node'))
21
+ throw "only binary versions are supported for now"
22
+ const update = await getUpdate()
23
+ const assetSearch = ({ win32: 'windows', darwin: 'mac', linux: 'linux' } as any)[process.platform]
24
+ if (!assetSearch)
25
+ throw "this feature doesn't support your platform: " + process.platform
26
+ const asset = update.assets.find((x: any) => x.name.endsWith('.zip') && x.name.includes(assetSearch))
27
+ if (!asset)
28
+ throw "asset not found"
29
+ const url = asset.browser_download_url
30
+ console.log("downloading", url)
31
+ const bin = process.argv[0]
32
+ const binPath = dirname(bin)
33
+ const binFile = basename(bin)
34
+ const newBinFile = 'new-' + binFile
35
+ pluginsWatcher.pause()
36
+ try {
37
+ await unzip(await httpsStream(url), path =>
38
+ join(binPath, path === binFile ? newBinFile : path))
39
+ const newBin = join(binPath, newBinFile)
40
+ if (!IS_WINDOWS) {
41
+ const { mode } = await stat(bin)
42
+ await chmod(newBin, mode).catch(console.error)
43
+ }
44
+ onProcessExit(() => {
45
+ const oldBinFile = 'old-' + binFile
46
+ console.log("old version is kept as", oldBinFile)
47
+ const oldBin = join(binPath, oldBinFile)
48
+ try { unlinkSync(oldBin) }
49
+ catch {}
50
+ renameSync(bin, oldBin)
51
+ console.log("launching new version in background", newBinFile)
52
+ spawn(newBin, ['--updating', binFile], { detached: true, shell: true })
53
+ .on('error', console.error)
54
+ })
55
+ console.log('quitting')
56
+ process.exit()
57
+ }
58
+ catch {
59
+ pluginsWatcher.unpause()
60
+ }
61
+ }
62
+
63
+ if (argv.updating) { // we were launched with a temporary name, restore original name to avoid breaking references
64
+ const bin = process.argv[0]
65
+ renameSync(bin, join(dirname(bin), argv.updating))
66
+ console.log("renamed binary file to", argv.updating)
67
+ }
@@ -0,0 +1,137 @@
1
+ import fs from 'fs/promises'
2
+ import { wait } from './misc'
3
+ import { createWriteStream, mkdirSync, watch } from 'fs'
4
+ import { basename, dirname } from 'path'
5
+ import glob from 'fast-glob'
6
+ import { IS_WINDOWS } from './const'
7
+ import { execFile } from 'child_process'
8
+ import { once, Readable } from 'stream'
9
+ // @ts-ignore
10
+ import unzipper from 'unzip-stream'
11
+
12
+ export async function isDirectory(path: string) {
13
+ try { return (await fs.stat(path)).isDirectory() }
14
+ catch { return false }
15
+ }
16
+
17
+ export async function isFile(path: string) {
18
+ try { return (await fs.stat(path)).isFile() }
19
+ catch { return false }
20
+ }
21
+
22
+ export async function readFileBusy(path: string): Promise<string> {
23
+ return fs.readFile(path, 'utf8').catch(e => {
24
+ if ((e as any)?.code !== 'EBUSY')
25
+ throw e
26
+ console.debug('busy')
27
+ return wait(100).then(()=> readFileBusy(path))
28
+ })
29
+ }
30
+
31
+ export function watchDir(dir: string, cb: ()=>void) {
32
+ let watcher: ReturnType<typeof watch>
33
+ let paused = false
34
+ try {
35
+ watcher = watch(dir, controlledCb)
36
+ }
37
+ catch {
38
+ // failing watching the content of the dir, we try to monitor its parent, but filtering events only for our target dir
39
+ const base = basename(dir)
40
+ try {
41
+ watcher = watch(dirname(dir), (event,name) => {
42
+ if (name !== base) return
43
+ try {
44
+ watcher.close() // if we succeed, we give up the parent watching
45
+ watcher = watch(dir, controlledCb) // attempt at passing to a more specific watching
46
+ }
47
+ catch {}
48
+ controlledCb()
49
+ })
50
+ }
51
+ catch (e) {
52
+ console.debug(String(e))
53
+ }
54
+ }
55
+ return {
56
+ working() { return Boolean(watcher) },
57
+ stop() { watcher?.close() },
58
+ pause() { paused = true },
59
+ unpause() { paused = false },
60
+ }
61
+
62
+ function controlledCb() {
63
+ if (!paused)
64
+ cb()
65
+ }
66
+ }
67
+
68
+ export function dirTraversal(s?: string) {
69
+ return s && /(^|[/\\])\.\.($|[/\\])/.test(s)
70
+ }
71
+
72
+ export function isWindowsDrive(s?: string) {
73
+ return s && /^[a-zA-Z]:$/.test(s)
74
+ }
75
+
76
+ // apply this to paths that may contain \ as separator (not supported by fast-glob) and other special chars to be escaped (parenthesis)
77
+ export function adjustStaticPathForGlob(path: string) {
78
+ return glob.escapePath(path.replace(/\\/g, '/'))
79
+ }
80
+
81
+ export async function* dirStream(path: string, deep?: number) {
82
+ if (!await isDirectory(path))
83
+ throw Error('ENOTDIR')
84
+ const dirStream = glob.stream(deep ? '**/*' : '*', {
85
+ cwd: path,
86
+ dot: true,
87
+ deep,
88
+ onlyFiles: false,
89
+ suppressErrors: true,
90
+ objectMode: true,
91
+ })
92
+ const skip = await getItemsToSkip(path)
93
+ for await (const entry of dirStream) {
94
+ let { path, dirent } = entry as any
95
+ if (!dirent.isDirectory() && !dirent.isFile()) continue
96
+ path = String(path)
97
+ if (!skip?.includes(path))
98
+ yield path
99
+ }
100
+
101
+ async function getItemsToSkip(path: string) {
102
+ if (!IS_WINDOWS) return
103
+ const out = await run('dir', ['/ah', '/b', path.replace(/\//g, '\\')])
104
+ .catch(()=>'') // error in case of no matching file
105
+ return out.split('\r\n').slice(0,-1)
106
+ }
107
+ }
108
+
109
+ export function run(cmd: string, args: string[] = []): Promise<string> {
110
+ return new Promise((resolve, reject) =>
111
+ execFile('cmd', ['/c', cmd, ...args], (err, stdout) => {
112
+ if (err)
113
+ reject(err)
114
+ else
115
+ resolve(stdout)
116
+ }))
117
+ }
118
+
119
+ export async function unzip(stream: Readable, cb: (path: string) => false | string) {
120
+ let pending: Promise<any> = Promise.resolve()
121
+ return new Promise(resolve =>
122
+ stream.pipe(unzipper.Parse())
123
+ .on('end', () => pending.then(resolve))
124
+ .on('entry', async (entry: any) => {
125
+ const { path, type } = entry
126
+ const dest = cb(path)
127
+ if (!dest || type !== 'File')
128
+ return entry.autodrain()
129
+ await pending // don't overlap writings
130
+ console.debug('unzip', dest)
131
+ mkdirSync(dirname(dest), { recursive: true }) // easy way be sure to have the folder ready before proceeding
132
+ const thisFile = entry.pipe(createWriteStream(dest))
133
+ pending = once(thisFile, 'finish')
134
+ })
135
+ )
136
+ }
137
+
@@ -0,0 +1,29 @@
1
+ // callback can return undefined to skip element
2
+ import { Readable } from 'stream'
3
+
4
+ export async function* filterMapGenerator<IN,OUT>(generator: AsyncIterableIterator<IN>, filterMap: (el: IN) => Promise<OUT>) {
5
+ for await (const x of generator) {
6
+ const res:OUT = await filterMap(x)
7
+ if (res !== undefined)
8
+ yield res as Exclude<OUT,undefined>
9
+ }
10
+ }
11
+
12
+ export async function asyncGeneratorToArray<T>(generator: AsyncIterable<T>): Promise<T[]> {
13
+ const ret: T[] = []
14
+ for await(const x of generator)
15
+ ret.push(x)
16
+ return ret
17
+ }
18
+
19
+ export function asyncGeneratorToReadable<T>(generator: AsyncIterable<T>) {
20
+ const iterator = generator[Symbol.asyncIterator]()
21
+ return new Readable({
22
+ objectMode: true,
23
+ read() {
24
+ iterator.next().then(it =>
25
+ this.push(it.done ? null : it.value))
26
+ }
27
+ })
28
+ }
29
+
@@ -0,0 +1,29 @@
1
+ import { RequestOptions } from 'https'
2
+ import { IncomingMessage } from 'node:http'
3
+ import https from 'node:https'
4
+
5
+ export function httpsString(url: string, options:RequestOptions={}): Promise<IncomingMessage & { ok: boolean, body: string }> {
6
+ return httpsStream(url, options).then(res =>
7
+ new Promise(resolve => {
8
+ let buf = ''
9
+ res.on('data', chunk => buf += chunk.toString())
10
+ res.on('end', () => resolve(Object.assign(res, {
11
+ ok: (res.statusCode || 400) < 400,
12
+ body: buf
13
+ })))
14
+ })
15
+ )
16
+ }
17
+
18
+ export function httpsStream(url: string, options:RequestOptions={}): Promise<IncomingMessage> {
19
+ return new Promise((resolve, reject) => {
20
+ https.request(url, options, res => {
21
+ if (!res.statusCode || res.statusCode >= 400)
22
+ throw res
23
+ if (res.statusCode === 302 && res.headers.location)
24
+ return resolve(httpsStream(res.headers.location, options))
25
+ resolve(res)
26
+ }).on('error', reject).end()
27
+ })
28
+ }
29
+