mikser-io-whitebox 4.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Almero Digital Marketing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # mikser-io-whitebox
2
+
3
+ WhiteBox integration for [Mikser](https://github.com/almero-digital-marketing/mikser-io). Pushes processed entities to a WhiteBox `feed` service and synchronises a watched folder with a WhiteBox `storage` service.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install mikser-io-whitebox
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ // mikser.config.js
15
+ export default {
16
+ plugins: ['whitebox'],
17
+ whitebox: {
18
+ context: 'my-project',
19
+ services: {
20
+ feed: {
21
+ url: 'https://feed.example.com',
22
+ token: 'FEED_TOKEN',
23
+ expire: '10 days',
24
+ match: (entity) => entity.type === 'document'
25
+ },
26
+ storage: {
27
+ url: 'https://storage.example.com',
28
+ token: 'STORAGE_TOKEN',
29
+ storageFolder: 'storage',
30
+ expire: '10 days',
31
+ match: (entity) => entity.id.startsWith('/storage/')
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Either service is optional — omit `services.feed` or `services.storage` to disable that half. When `context` is not set, the plugin falls back to a per-machine id (`machineId_hostname_username`).
39
+
40
+ ### Feed
41
+
42
+ Catalogs entities matching `feed.match` (default: `entity.type === 'document'`) into the WhiteBox feed on every `processed` phase, and expires/clears the cache after each run.
43
+
44
+ ### Storage
45
+
46
+ Watches `storageFolder` (default `storage/`) and uploads matching entities — by source on `processed`, and by render output on `finalize`. `storage.match` defaults to `entity.id` containing `/storage/`. Imports existing files on startup; with `--clear` the remote storage is wiped first.
47
+
48
+ ## License
49
+
50
+ MIT
package/index.js ADDED
@@ -0,0 +1,49 @@
1
+ import { hostname, userInfo } from 'os'
2
+ import axios from 'axios'
3
+ import MID from 'node-machine-id'
4
+
5
+ import feed from './src/feed.js'
6
+ import storage from './src/storage.js'
7
+
8
+ export default (core) => {
9
+ const { runtime, useLogger, onLoaded } = core
10
+
11
+ async function whiteboxApi(service, route, data) {
12
+ const logger = useLogger()
13
+ const { services } = runtime.config.whitebox
14
+ const { url, token } = services[service]
15
+ if (!url || !token) return
16
+
17
+ try {
18
+ const response = await axios.post(url + route + '?stamp=' + Date.now(), data, {
19
+ headers: {
20
+ Authorization: 'Bearer ' + token,
21
+ }
22
+ })
23
+ if (response.data.success) return response.data
24
+ } catch (err) {
25
+ logger.error(err, 'WhiteBox system error: %s %o', route, data)
26
+ }
27
+ }
28
+
29
+ let machineId
30
+ async function useMachineId() {
31
+ if (!machineId) {
32
+ machineId = await MID.machineId() + '_' + hostname() + '_' + userInfo().username
33
+ }
34
+ return machineId
35
+ }
36
+
37
+ onLoaded(() => {
38
+ const logger = useLogger()
39
+ logger.info('WhiteBox context: %s', runtime.config.whitebox?.context)
40
+ })
41
+
42
+ return {
43
+ whiteboxApi,
44
+ useMachineId,
45
+ ...feed({ ...core, whiteboxApi, useMachineId }),
46
+ ...storage({ ...core, whiteboxApi, useMachineId }),
47
+ }
48
+
49
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "mikser-io-whitebox",
3
+ "version": "4.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/almero-digital-marketing/mikser-io-whitebox.git"
13
+ },
14
+ "author": "",
15
+ "license": "MIT",
16
+ "bugs": {
17
+ "url": "https://github.com/almero-digital-marketing/mikser-io-whitebox/issues"
18
+ },
19
+ "homepage": "https://github.com/almero-digital-marketing/mikser-io-whitebox#readme",
20
+ "peerDependencies": {
21
+ "mikser-io": "^6.0.0"
22
+ },
23
+ "dependencies": {
24
+ "aguid": "^2.0.0",
25
+ "axios": "^1.16.0",
26
+ "form-data": "^4.0.5",
27
+ "globby": "^16.2.0",
28
+ "lodash": "^4.18.1",
29
+ "node-machine-id": "^1.1.12",
30
+ "p-map": "^7.0.4",
31
+ "uuid": "^14.0.0"
32
+ }
33
+ }
package/src/feed.js ADDED
@@ -0,0 +1,108 @@
1
+ import pMap from 'p-map'
2
+ import { v1 as uuidv1 } from 'uuid'
3
+ import aguid from 'aguid'
4
+ import _ from 'lodash'
5
+
6
+ export default ({
7
+ runtime,
8
+ onProcessed,
9
+ useLogger,
10
+ useJournal,
11
+ onLoaded,
12
+ whiteboxApi,
13
+ useMachineId,
14
+ constants: { OPERATION },
15
+ }) => {
16
+ let types = new Set()
17
+
18
+ async function expireCatalog() {
19
+ const { context } = runtime.config.whitebox
20
+ for (let type of types) {
21
+ await whiteboxApi('feed', '/api/catalog/expire', {
22
+ context: context || await useMachineId(),
23
+ stamp: runtime.stamp,
24
+ type
25
+ })
26
+ }
27
+ }
28
+
29
+ async function clearCache() {
30
+ const logger = useLogger()
31
+ logger.debug('WhiteBox feed %s: %s', 'clear', 'cache')
32
+ const { context } = runtime.config.whitebox
33
+ let data = {
34
+ context: context || await useMachineId()
35
+ }
36
+ return whiteboxApi('feed', '/api/catalog/clear/cache', data)
37
+ }
38
+
39
+ onLoaded(async () => {
40
+ const logger = useLogger()
41
+ if (runtime.options.clear) {
42
+ const { context } = runtime.config.whitebox
43
+ const data = {
44
+ context: context || await useMachineId()
45
+ }
46
+
47
+ logger.debug('WhiteBox feed %s: %s', 'clear', 'catalog')
48
+ await whiteboxApi('feed', '/api/catalog/clear', data)
49
+ await clearCache()
50
+ }
51
+ })
52
+
53
+ onProcessed(async (signal) => {
54
+ const logger = useLogger()
55
+ const { context, services: { feed } } = runtime.config.whitebox || { services: {} }
56
+ if (!feed) return
57
+
58
+ let added = 0
59
+ let deleted = 0
60
+ await pMap(useJournal('WhiteBox feed', [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE], signal), async ({ entity, operation }) => {
61
+ if (entity.meta && (feed.match && feed.match(entity) || !feed.match && entity.type == 'document')) {
62
+ switch (operation) {
63
+ case OPERATION.CREATE:
64
+ case OPERATION.UPDATE:
65
+ added++
66
+ if (!entity.name || !entity.id) {
67
+ logger.warn(entity, 'WhiteBox feed skipping')
68
+ return
69
+ }
70
+ logger.trace('WhiteBox feed: %s', entity.id)
71
+ const keepData = {
72
+ passportId: uuidv1(),
73
+ vaultId: aguid(entity.id),
74
+ refId: '/' + entity.name.replace('index', ''),
75
+ type: 'mikser.' + (entity.meta.type || entity.type),
76
+ data: _.pick(entity, ['meta', 'stamp', 'content', 'type', 'collection', 'format', 'id', 'uri']),
77
+ date: new Date(entity.time),
78
+ vaults: entity.meta.vaults,
79
+ context: context || await useMachineId(),
80
+ expire: feed.expire === false ? false : feed.expire || '10 days'
81
+ }
82
+ types.add(keepData.type)
83
+
84
+ logger.debug('WhiteBox feed %s: %s %s', 'keep', entity.type, keepData.refId)
85
+ await whiteboxApi('feed', '/api/catalog/keep/one', keepData)
86
+ break
87
+ case OPERATION.DELETE:
88
+ deleted++
89
+ const removeData = {
90
+ vaultId: aguid(entity.id),
91
+ context: context || await useMachineId()
92
+ }
93
+
94
+ if (!runtime.options.clear) {
95
+ logger.debug('WhiteBox feed %s: %s %s', 'remove', entity.type, entity.id)
96
+ return whiteboxApi('feed', '/api/catalog/remove', removeData)
97
+ }
98
+ break
99
+ }
100
+ }
101
+ }, { concurrency: 4, signal })
102
+ logger.debug('WhiteBox feed %s: %s', 'keep', added)
103
+ logger.debug('WhiteBox feed %s: %s', 'remove', deleted)
104
+
105
+ await expireCatalog()
106
+ await clearCache()
107
+ })
108
+ }
package/src/storage.js ADDED
@@ -0,0 +1,292 @@
1
+ import pMap from 'p-map'
2
+ import fs from 'fs/promises'
3
+ import { createReadStream } from 'node:fs'
4
+ import axios from 'axios'
5
+ import FormData from 'form-data'
6
+ import path from 'path'
7
+ import { globby } from 'globby'
8
+ import { setTimeout } from 'timers/promises'
9
+
10
+ export default ({
11
+ runtime,
12
+ useLogger,
13
+ onLoaded,
14
+ onImport,
15
+ onSync,
16
+ onProcessed,
17
+ onFinalize,
18
+ useJournal,
19
+ useMachineId,
20
+ watch,
21
+ checksum,
22
+ matchEntity,
23
+ findEntity,
24
+ createEntity,
25
+ updateEntity,
26
+ deleteEntity,
27
+ constants: { ACTION, OPERATION },
28
+ }) => {
29
+ const collection = 'storage'
30
+ const type = 'file'
31
+
32
+ const pendingUploads = new Set()
33
+ const history = new Map()
34
+
35
+ async function upload(fileName, uploadName, uploadChecksum) {
36
+ const logger = useLogger()
37
+
38
+ if (pendingUploads.has(fileName)) return
39
+ pendingUploads.add(fileName)
40
+ if (history.get(uploadName) == uploadChecksum) return
41
+
42
+ const uploadWhenReady = async () => {
43
+ try {
44
+ const fh = await fs.open(fileName, 0x10000000)
45
+ try {
46
+ const { context, services: { storage } } = runtime.config.whitebox
47
+ let data = {
48
+ file: uploadName,
49
+ context: context || await useMachineId()
50
+ }
51
+ const responseHash = await axios.post(storage.url + '/' + storage.token + '/checksum', data)
52
+ const matchedHash = responseHash.data.success && uploadChecksum == responseHash.data.hash
53
+ logger.debug('WhiteBox storage %s: %s %s', 'checksum', fileName, matchedHash)
54
+ if (!matchedHash) {
55
+ const uploadHeaders = {
56
+ expire: storage.expire === false ? false : storage.expire || '10 days',
57
+ context: data.context
58
+ }
59
+ let form = new FormData()
60
+ form.append(uploadName, createReadStream(fileName))
61
+ let formHeaders = form.getHeaders()
62
+ try {
63
+ const responseUpload = await axios
64
+ .post(storage.url + '/upload', form, {
65
+ headers: {
66
+ Authorization: 'Bearer ' + storage.token,
67
+ ...formHeaders,
68
+ ...uploadHeaders,
69
+ },
70
+ maxContentLength: Infinity,
71
+ maxBodyLength: Infinity
72
+ })
73
+ if (responseUpload.data.uploads) {
74
+ for (let file in responseUpload.data.uploads) {
75
+ logger.debug('WhiteBox storage %s: %s', 'upload', uploadName)
76
+ logger.debug('WhiteBox storage %s: %s', 'link', responseUpload.data.uploads[file])
77
+ history.set(uploadName, uploadChecksum)
78
+ }
79
+ }
80
+ } catch (err) {
81
+ logger.error('WhiteBox storage upload error: %s', err.message)
82
+ }
83
+ } else {
84
+ history.set(uploadName, uploadChecksum)
85
+ logger.debug('WhiteBox storage %s: %s', 'skip', uploadName)
86
+ }
87
+ } catch (err) {
88
+ logger.error('WhiteBox storage error: %s', err.message)
89
+ }
90
+ fh.close()
91
+ } catch (err) {
92
+ logger.trace(err, 'WhiteBox storage postponed: %s', uploadName)
93
+ await setTimeout(3000)
94
+ await uploadWhenReady()
95
+ }
96
+ }
97
+ await uploadWhenReady()
98
+ pendingUploads.delete(fileName)
99
+ }
100
+
101
+ async function link(uploadName) {
102
+ const logger = useLogger()
103
+ const { context, services: { storage } } = runtime.config.whitebox
104
+ let data = {
105
+ file: uploadName,
106
+ context: context || await useMachineId()
107
+ }
108
+ try {
109
+ const response = await axios.post(storage.url + '/' + storage.token + '/link', data)
110
+ logger.debug('WhiteBox storage %s: %s', 'link', response.data?.link)
111
+ return response.data?.link
112
+ } catch (err) {
113
+ logger.trace('WhiteBox storage error: %s', err)
114
+ }
115
+ }
116
+
117
+ async function unlink(uploadName) {
118
+ const logger = useLogger()
119
+ const { context, services: { storage } } = runtime.config.whitebox
120
+ let data = {
121
+ file: uploadName,
122
+ context: context || await useMachineId()
123
+ }
124
+ try {
125
+ await axios.post(storage.url + '/' + storage.token + '/unlink', data)
126
+ logger.debug('WhiteBox storage: %s %s', 'unlink', uploadName)
127
+ } catch (err) {
128
+ logger.trace('WhiteBox storage error: %s', err)
129
+ }
130
+ }
131
+
132
+ onImport(async () => {
133
+ const logger = useLogger()
134
+ const paths = await globby('**/*', { cwd: runtime.options.storageFolder })
135
+ logger.info('Importing whitebox storage: %d', paths.length)
136
+
137
+ return Promise.all(paths.map(async relativePath => {
138
+ const source = path.join(runtime.options.storageFolder, relativePath)
139
+ const uploadName = source.replace(runtime.options.workingFolder, '')
140
+
141
+ await createEntity({
142
+ id: path.join(`/${collection}`, relativePath),
143
+ uri: uploadName,
144
+ collection,
145
+ type,
146
+ format: path.extname(relativePath).substring(1).toLowerCase(),
147
+ name: relativePath,
148
+ source,
149
+ checksum: await checksum(source),
150
+ link: await link(uploadName)
151
+ })
152
+ }))
153
+ })
154
+
155
+ onProcessed(async (signal) => {
156
+ const logger = useLogger()
157
+ const { services: { storage } } = runtime.config.whitebox || { services: {} }
158
+ if (!storage) return
159
+
160
+ let added = 0
161
+ let deleted = 0
162
+ await pMap(useJournal('WhiteBox storage processing', [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE], signal), async ({ entity, operation }) => {
163
+ const match = storage.match || ((entity) => entity.id.indexOf('/storage/') != -1)
164
+ if (matchEntity(entity, match)) {
165
+ const uploadName = entity.source.replace(runtime.options.workingFolder, '')
166
+ switch (operation) {
167
+ case OPERATION.CREATE:
168
+ case OPERATION.UPDATE:
169
+ added++
170
+ await upload(entity.source, uploadName, entity.checksum)
171
+ break
172
+ case OPERATION.DELETE:
173
+ deleted++
174
+ await unlink(uploadName)
175
+ break
176
+ }
177
+ }
178
+ }, { concurrency: 4, signal })
179
+
180
+ logger.debug('WhiteBox storage %s: %s', 'upload', added)
181
+ logger.debug('WhiteBox storage %s: %s', 'unlink', deleted)
182
+ })
183
+
184
+ onFinalize(async (signal) => {
185
+ const logger = useLogger()
186
+ const { services: { storage } } = runtime.config.whitebox || { services: {} }
187
+ if (!storage) return
188
+
189
+ await pMap(useJournal('WhiteBox storage output', [OPERATION.RENDER], signal), async ({ entity, output }) => {
190
+ if (output?.success) {
191
+ if (storage.match && storage.match(entity) || !storage.match && entity.id.indexOf('/storage/') != -1) {
192
+ const uploadName = entity.destination.replace(runtime.options.outputFolder, '').replace(runtime.options.workingFolder, '')
193
+ try {
194
+ const uploadChecksum = await checksum(entity.destination)
195
+ await upload(entity.destination, uploadName, uploadChecksum)
196
+ } catch (err) {
197
+ if (err.code == 'ENOENT') {
198
+ logger.error('Output is missing: %s', entity.destination)
199
+ } else {
200
+ logger.error('WhiteBox storage error: %s', err.message)
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }, { concurrency: 4, signal })
206
+ })
207
+
208
+ onLoaded(async () => {
209
+ const logger = useLogger()
210
+ const { context, services: { storage } } = runtime.config.whitebox
211
+ if (!storage) return
212
+
213
+ runtime.options.storage = storage?.storageFolder || collection
214
+ runtime.options.storageFolder = path.join(runtime.options.workingFolder, runtime.options.storage)
215
+
216
+ if (runtime.options.clear) {
217
+ const data = {
218
+ context: context || await useMachineId()
219
+ }
220
+ try {
221
+ logger.info('WhiteBox storage: %s', 'clear')
222
+ await axios.post(storage.url + '/' + storage.token + '/clear', data)
223
+ } catch (err) {
224
+ logger.error('WhiteBox storage error: %s', err.message)
225
+ }
226
+ }
227
+
228
+ logger.info('WhiteBox storage folder: %s', runtime.options.storageFolder)
229
+ await fs.mkdir(runtime.options.storageFolder, { recursive: true })
230
+
231
+ watch(collection, runtime.options.storageFolder)
232
+ })
233
+
234
+ onSync(collection, async ({ action, context }) => {
235
+ if (!context.relativePath) return false
236
+ const { relativePath } = context
237
+
238
+ const source = path.join(runtime.options.storageFolder, relativePath)
239
+ const format = path.extname(relativePath).substring(1).toLowerCase()
240
+ const id = path.join(`/${collection}`, relativePath)
241
+ const uploadName = source.replace(runtime.options.workingFolder, '')
242
+
243
+ let synced = true
244
+ switch (action) {
245
+ case ACTION.CREATE:
246
+ await createEntity({
247
+ id,
248
+ uri: uploadName,
249
+ name: relativePath,
250
+ collection,
251
+ type,
252
+ format,
253
+ source,
254
+ checksum: await checksum(source),
255
+ link: await link(uploadName)
256
+ })
257
+ break
258
+ case ACTION.UPDATE:
259
+ const current = await findEntity({ id })
260
+ if (current?.checksum != checksum) {
261
+ await updateEntity({
262
+ id,
263
+ uri: uploadName,
264
+ name: relativePath,
265
+ collection,
266
+ type,
267
+ format,
268
+ source,
269
+ checksum: await checksum(source),
270
+ link: await link(uploadName)
271
+ })
272
+ } else {
273
+ synced = false
274
+ }
275
+ break
276
+ case ACTION.DELETE:
277
+ await unlink(uploadName)
278
+ await deleteEntity({
279
+ id,
280
+ collection,
281
+ type,
282
+ })
283
+ break
284
+ }
285
+ return synced
286
+ })
287
+
288
+ return {
289
+ collection,
290
+ type
291
+ }
292
+ }