@sasjs/cli 6.10.0 → 6.11.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/build/commands/build/internal/mockSite/assets/runtime-core.js +248 -2
- package/build/commands/build/internal/mockSite/assets/sw.js +209 -14
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +255 -6
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +576 -4
- package/package.json +1 -1
|
@@ -954,6 +954,35 @@ const resolveProgram = (program) => {
|
|
|
954
954
|
return null
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
/**
|
|
958
|
+
* The header file a session starts with. sasjs/server seeds this exact line
|
|
959
|
+
* into a JS session (controllers/internal/Session.ts), so a service that
|
|
960
|
+
* declares no type answers text/html rather than leaving the choice to the
|
|
961
|
+
* browser - a mock build matches that here.
|
|
962
|
+
*
|
|
963
|
+
* The trailing newline is part of it: the server writes it for a JS session
|
|
964
|
+
* (`'content-type: text/html; charset=utf-8\n'` in Session.ts, as against the
|
|
965
|
+
* SAS 9 session's seed with no newline), and it is what keeps a service that
|
|
966
|
+
* appends a header without a leading newline on its own line - otherwise the two
|
|
967
|
+
* concatenate into one unusable value.
|
|
968
|
+
*
|
|
969
|
+
* It is a default, not a declaration: the worker ignores it for a byte body,
|
|
970
|
+
* so a service that writes bytes and names no type is still an undeclared
|
|
971
|
+
* download rather than being labelled text/html. The worker reads the value
|
|
972
|
+
* from HEADERS_SEED below, so the seed and the rule cannot drift.
|
|
973
|
+
*/
|
|
974
|
+
const SEEDED_HEADERS = 'content-type: text/html; charset=utf-8\n'
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* The largest body a single upload may carry.
|
|
978
|
+
*
|
|
979
|
+
* A browser worker holds the whole payload in memory, so an oversized upload is
|
|
980
|
+
* an out-of-memory failure rather than a limit a client can act on. This is the
|
|
981
|
+
* same 10 MB a deploy allows for one file, and it is checked from the File's
|
|
982
|
+
* `size` before the body is read.
|
|
983
|
+
*/
|
|
984
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
|
985
|
+
|
|
957
986
|
const runProgram = ({ program, code, params = {}, tables = [] }) => {
|
|
958
987
|
const payloadCode = code
|
|
959
988
|
const codePath = program ? resolveProgram(program) : null
|
|
@@ -977,7 +1006,7 @@ const runProgram = ({ program, code, params = {}, tables = [] }) => {
|
|
|
977
1006
|
const headersPath = `${sessionDir}/stpsrv_header.txt`
|
|
978
1007
|
const tokenFile = `${sessionDir}/reqHeaders.txt`
|
|
979
1008
|
FILES.set(weboutPath, '')
|
|
980
|
-
FILES.set(headersPath,
|
|
1009
|
+
FILES.set(headersPath, SEEDED_HEADERS)
|
|
981
1010
|
FILES.set(tokenFile, '')
|
|
982
1011
|
|
|
983
1012
|
const varStatements = Object.entries(params)
|
|
@@ -1144,7 +1173,9 @@ const readDriveFile = (filePath) => {
|
|
|
1144
1173
|
String(filePath).replace(/^\/+/, '')
|
|
1145
1174
|
)
|
|
1146
1175
|
if (!FILES.has(abs)) {
|
|
1147
|
-
|
|
1176
|
+
// the server's wording, because a client prints this: GET /drive/file
|
|
1177
|
+
// answers `res.status(404).send(err.message)`
|
|
1178
|
+
const err = new Error(`File doesn't exist.`)
|
|
1148
1179
|
err.status = 404
|
|
1149
1180
|
throw err
|
|
1150
1181
|
}
|
|
@@ -1160,6 +1191,207 @@ const readDriveFile = (filePath) => {
|
|
|
1160
1191
|
/** The stored base64 of a byte entry, once it is known to decode. */
|
|
1161
1192
|
const validateEntry = (contents) => cleanedBase64(contents)
|
|
1162
1193
|
|
|
1194
|
+
// ------------------------------------------------------------- drive writes
|
|
1195
|
+
// The /drive endpoints answer the same contract sasjs/server serves, so a client
|
|
1196
|
+
// that uploads, renames or deletes needs no change for a mock build - only the
|
|
1197
|
+
// storage differs (a Map here, a folder on the server). Every mutation marks the
|
|
1198
|
+
// entry dirty, which is how the page learns to persist it: the worker can be
|
|
1199
|
+
// terminated between requests, so a write it did not hand back would live only
|
|
1200
|
+
// until the next reload.
|
|
1201
|
+
|
|
1202
|
+
const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
|
|
1203
|
+
|
|
1204
|
+
const driveError = (status, message) => {
|
|
1205
|
+
const error = new Error(message)
|
|
1206
|
+
error.status = status
|
|
1207
|
+
return error
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* A caller-supplied path as the absolute path under the drive root, or null when
|
|
1212
|
+
* it lands outside - the rule sasjs/server applies in resolveWithinDrive. A
|
|
1213
|
+
* leading slash means "under the drive root", never filesystem absolute, and the
|
|
1214
|
+
* test is on the whole normalised path rather than a substring: `/drive/filesx`
|
|
1215
|
+
* is not inside `/drive/files`.
|
|
1216
|
+
*/
|
|
1217
|
+
const drivePath = (unsafePath) => {
|
|
1218
|
+
const root = DRIVE_FILES()
|
|
1219
|
+
const normalised = String(
|
|
1220
|
+
unsafePath === undefined || unsafePath === null ? '' : unsafePath
|
|
1221
|
+
)
|
|
1222
|
+
.replace(/\\/g, '/')
|
|
1223
|
+
.replace(/^\/+/, '')
|
|
1224
|
+
const resolved = P.resolve(root, normalised)
|
|
1225
|
+
return resolved === root || resolved.startsWith(root + '/') ? resolved : null
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Writes one entry: bytes stay bytes (behind the base64 marker the drive reads
|
|
1230
|
+
* them back from), a string is text, and the entry is marked for persistence.
|
|
1231
|
+
*
|
|
1232
|
+
* An upload arrives as bytes and is stored as bytes, which is also what the
|
|
1233
|
+
* server does: multer writes the request body to disk and `saveFile` moves that
|
|
1234
|
+
* file, so an uploaded CSV is a byte file there too - a program reading it
|
|
1235
|
+
* without an encoding gets a Buffer on both hosts, and with `'utf8'` a string.
|
|
1236
|
+
* The difference a client actually sees is the content type a download is served
|
|
1237
|
+
* with, and that is decided by the extension on the GET route, as `sendFile`
|
|
1238
|
+
* does on the server.
|
|
1239
|
+
*/
|
|
1240
|
+
const storeEntry = (abs, data) => {
|
|
1241
|
+
const bytes = asBytes(data)
|
|
1242
|
+
FILES.set(
|
|
1243
|
+
abs,
|
|
1244
|
+
bytes
|
|
1245
|
+
? BINARY_PREFIX + bytesToBase64(bytes)
|
|
1246
|
+
: encodeText(String(data === undefined || data === null ? '' : data))
|
|
1247
|
+
)
|
|
1248
|
+
SIZES.delete(abs)
|
|
1249
|
+
markDirty(abs)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* The ancestor of a path that is itself a file, if there is one. `mkdirp` would
|
|
1254
|
+
* happily turn `/data/x.csv` into a folder to hold `/data/x.csv/child`, leaving a
|
|
1255
|
+
* path that is a file and has children at the same time - a state the server
|
|
1256
|
+
* never reaches, because the filesystem refuses it (ENOTDIR).
|
|
1257
|
+
*/
|
|
1258
|
+
const fileAncestorOf = (abs) => {
|
|
1259
|
+
const parts = abs.split('/').filter(Boolean)
|
|
1260
|
+
let acc = ''
|
|
1261
|
+
for (const part of parts.slice(0, -1)) {
|
|
1262
|
+
acc += '/' + part
|
|
1263
|
+
if (FILES.has(acc)) return acc
|
|
1264
|
+
}
|
|
1265
|
+
return null
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const saveDriveFile = (filePath, data) => {
|
|
1269
|
+
const abs = drivePath(filePath)
|
|
1270
|
+
if (!abs || abs === DRIVE_FILES())
|
|
1271
|
+
throw driveError(400, `Can't put file outside drive.`)
|
|
1272
|
+
// a folder at the same path would leave one path present as both, which the
|
|
1273
|
+
// server fails at the filesystem (EISDIR) rather than reporting as a conflict
|
|
1274
|
+
if (FILES.has(abs) || DIRS.has(abs))
|
|
1275
|
+
throw driveError(409, 'File already exists.')
|
|
1276
|
+
// the server fails this at the filesystem with ENOTDIR and answers a 500; a
|
|
1277
|
+
// named refusal is more use to a client than reproducing that, so it is a 400
|
|
1278
|
+
const ancestor = fileAncestorOf(abs)
|
|
1279
|
+
if (ancestor)
|
|
1280
|
+
// the drive-relative path, never the host's absolute one: a client has no
|
|
1281
|
+
// business knowing where the drive lives
|
|
1282
|
+
throw driveError(
|
|
1283
|
+
400,
|
|
1284
|
+
`An ancestor of the path is a file: ${ancestor.slice(DRIVE_FILES().length) || '/'}`
|
|
1285
|
+
)
|
|
1286
|
+
mkdirp(P.dirname(abs))
|
|
1287
|
+
storeEntry(abs, data)
|
|
1288
|
+
return { status: 'success' }
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const updateDriveFile = (filePath, data) => {
|
|
1292
|
+
const abs = drivePath(filePath)
|
|
1293
|
+
if (!abs || abs === DRIVE_FILES())
|
|
1294
|
+
throw driveError(400, `Can't modify file outside drive.`)
|
|
1295
|
+
if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
|
|
1296
|
+
storeEntry(abs, data)
|
|
1297
|
+
return { status: 'success' }
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const removeDriveFile = (filePath) => {
|
|
1301
|
+
const abs = drivePath(filePath)
|
|
1302
|
+
if (!abs || abs === DRIVE_FILES())
|
|
1303
|
+
throw driveError(400, `Can't delete file outside drive.`)
|
|
1304
|
+
if (!FILES.has(abs)) throw driveError(404, `File doesn't exist.`)
|
|
1305
|
+
FILES.delete(abs)
|
|
1306
|
+
SIZES.delete(abs)
|
|
1307
|
+
markDirty(abs)
|
|
1308
|
+
return { status: 'success' }
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const addDriveFolder = (folderPath) => {
|
|
1312
|
+
const abs = drivePath(folderPath)
|
|
1313
|
+
if (!abs || abs === DRIVE_FILES())
|
|
1314
|
+
throw driveError(400, `Can't put folder outside drive.`)
|
|
1315
|
+
if (DIRS.has(abs) || FILES.has(abs))
|
|
1316
|
+
throw driveError(409, 'Folder already exists.')
|
|
1317
|
+
mkdirp(abs)
|
|
1318
|
+
return { status: 'success' }
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Deletes a folder and everything under it. The drive is a flat map of paths, so
|
|
1323
|
+
* this is the entries' prefix rather than a directory walk - and each file it
|
|
1324
|
+
* removes is marked dirty (as a deletion) so the page drops it from IndexedDB
|
|
1325
|
+
* too. An empty folder is not represented in the persisted drive, which is a
|
|
1326
|
+
* map of files; see the note on the docs page.
|
|
1327
|
+
*/
|
|
1328
|
+
const removeDriveFolder = (folderPath) => {
|
|
1329
|
+
const abs = drivePath(folderPath)
|
|
1330
|
+
if (!abs || abs === DRIVE_FILES())
|
|
1331
|
+
throw driveError(400, `Can't delete folder outside drive.`)
|
|
1332
|
+
if (!DIRS.has(abs)) throw driveError(404, `Folder doesn't exist.`)
|
|
1333
|
+
const prefix = abs + '/'
|
|
1334
|
+
for (const path of [...FILES.keys()]) {
|
|
1335
|
+
if (!path.startsWith(prefix)) continue
|
|
1336
|
+
FILES.delete(path)
|
|
1337
|
+
SIZES.delete(path)
|
|
1338
|
+
markDirty(path)
|
|
1339
|
+
}
|
|
1340
|
+
for (const dir of [...DIRS]) {
|
|
1341
|
+
if (dir === abs || dir.startsWith(prefix)) DIRS.delete(dir)
|
|
1342
|
+
}
|
|
1343
|
+
return { status: 'success' }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** Moves one stored file, leaving both paths for the page to persist. */
|
|
1347
|
+
const moveEntry = (from, to) => {
|
|
1348
|
+
FILES.set(to, FILES.get(from))
|
|
1349
|
+
FILES.delete(from)
|
|
1350
|
+
SIZES.delete(from)
|
|
1351
|
+
SIZES.delete(to)
|
|
1352
|
+
markDirty(to)
|
|
1353
|
+
markDirty(from)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const renameDriveEntry = (oldPath, newPath) => {
|
|
1357
|
+
const from = drivePath(oldPath)
|
|
1358
|
+
if (!from || from === DRIVE_FILES())
|
|
1359
|
+
throw driveError(400, `Old path can't be outside of drive.`)
|
|
1360
|
+
const to = drivePath(newPath)
|
|
1361
|
+
if (!to || to === DRIVE_FILES())
|
|
1362
|
+
throw driveError(400, `New path can't be outside of drive.`)
|
|
1363
|
+
|
|
1364
|
+
if (DIRS.has(from)) {
|
|
1365
|
+
// the server fails this at the filesystem; naming it here says why
|
|
1366
|
+
if (to.startsWith(from + '/'))
|
|
1367
|
+
throw driveError(400, `New path can't be inside the folder being moved.`)
|
|
1368
|
+
if (DIRS.has(to) || FILES.has(to))
|
|
1369
|
+
throw driveError(409, 'Folder with new name already exists.')
|
|
1370
|
+
const prefix = from + '/'
|
|
1371
|
+
mkdirp(P.dirname(to))
|
|
1372
|
+
for (const dir of [...DIRS]) {
|
|
1373
|
+
if (dir === from || dir.startsWith(prefix)) {
|
|
1374
|
+
DIRS.delete(dir)
|
|
1375
|
+
mkdirp(to + dir.slice(from.length))
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
for (const path of [...FILES.keys()]) {
|
|
1379
|
+
if (path.startsWith(prefix)) moveEntry(path, to + path.slice(from.length))
|
|
1380
|
+
}
|
|
1381
|
+
return { status: 'success' }
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (FILES.has(from)) {
|
|
1385
|
+
if (FILES.has(to) || DIRS.has(to))
|
|
1386
|
+
throw driveError(409, 'File with new name already exists.')
|
|
1387
|
+
mkdirp(P.dirname(to))
|
|
1388
|
+
moveEntry(from, to)
|
|
1389
|
+
return { status: 'success' }
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
throw driveError(404, 'No file/folder found for provided path.')
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1163
1395
|
const buildTree = () => {
|
|
1164
1396
|
const walk = (dir) => {
|
|
1165
1397
|
const node = {
|
|
@@ -1337,7 +1569,21 @@ const api = {
|
|
|
1337
1569
|
run: (payload) => runProgram(payload),
|
|
1338
1570
|
list: (folder) => listFolder(folder),
|
|
1339
1571
|
read: (file) => readDriveFile(file),
|
|
1572
|
+
// the write half of the drive API: each one answers the server's success body
|
|
1573
|
+
// or throws an error carrying the server's status and message
|
|
1574
|
+
save: (filePath, data) => saveDriveFile(filePath, data),
|
|
1575
|
+
update: (filePath, data) => updateDriveFile(filePath, data),
|
|
1576
|
+
remove: (filePath) => removeDriveFile(filePath),
|
|
1577
|
+
mkdir: (folderPath) => addDriveFolder(folderPath),
|
|
1578
|
+
rmdir: (folderPath) => removeDriveFolder(folderPath),
|
|
1579
|
+
rename: (oldPath, newPath) => renameDriveEntry(oldPath, newPath),
|
|
1340
1580
|
tree: () => buildTree(),
|
|
1581
|
+
// the content type a session's header file is seeded with, so the worker can
|
|
1582
|
+
// tell a default from a declaration rather than carrying a second literal
|
|
1583
|
+
HEADERS_SEED: SEEDED_HEADERS,
|
|
1584
|
+
// the largest single upload the host accepts, so the worker can refuse a body
|
|
1585
|
+
// before materialising it and the specs and docs read the same number
|
|
1586
|
+
MAX_UPLOAD_BYTES: MAX_UPLOAD_BYTES,
|
|
1341
1587
|
// the format the snapshot download stamps, so persist.js writes the number
|
|
1342
1588
|
// this runtime reads rather than a second literal
|
|
1343
1589
|
DRIVE_FORMAT
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* receives the changed files back after every run.
|
|
12
12
|
*
|
|
13
13
|
* TRUST MODEL: this is a single-user, no-auth host. It answers every /SASjsApi/
|
|
14
|
-
* path on the site's origin,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* a
|
|
19
|
-
*
|
|
14
|
+
* path on the site's origin, writes to the drive when a drive request names a
|
|
15
|
+
* path, runs whatever program the request names, and runs the code a
|
|
16
|
+
* code/execute request carries - the same contract a SASjs server offers, minus
|
|
17
|
+
* the server. Nothing here is a boundary: a page at this origin can already do
|
|
18
|
+
* all of it, because a visitor's own browser is the client. So a site holding
|
|
19
|
+
* anything private, or one served from a shared origin, belongs behind a real
|
|
20
|
+
* server rather than a static host.
|
|
20
21
|
*/
|
|
21
22
|
importScripts('/host/runtime-core.js')
|
|
22
23
|
|
|
@@ -101,6 +102,14 @@ const parseHeaders = (content) => {
|
|
|
101
102
|
return headers
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The content type a session's header file is seeded with - the server's
|
|
107
|
+
* default, not something the service asked for. The runtime owns the seed line,
|
|
108
|
+
* so the value is taken from it rather than repeated here: a byte body that
|
|
109
|
+
* carries only this type is an undeclared download, not a text/html one.
|
|
110
|
+
*/
|
|
111
|
+
const SEEDED_HEADERS = String(R.HEADERS_SEED)
|
|
112
|
+
|
|
104
113
|
/**
|
|
105
114
|
* The reply a run produces: the webout, in the shape and under the name the
|
|
106
115
|
* service asked for. A debug reply is the webout framed with the log, so it
|
|
@@ -114,6 +123,12 @@ const weboutResponse = (result, debug) => {
|
|
|
114
123
|
|
|
115
124
|
const headers = parseHeaders(result.headers)
|
|
116
125
|
if (result.binary) {
|
|
126
|
+
// the seed is a default, so a byte body that leaves the seeded file untouched
|
|
127
|
+
// declares nothing: it keeps the type an undeclared download gets. Compared as
|
|
128
|
+
// the raw text rather than as the parsed value, so a service that declares
|
|
129
|
+
// this same content type itself keeps it - only the untouched seed is dropped.
|
|
130
|
+
if (String(result.headers) === SEEDED_HEADERS)
|
|
131
|
+
delete headers['content-type']
|
|
117
132
|
if (!headers['content-type'])
|
|
118
133
|
headers['content-type'] = 'application/octet-stream'
|
|
119
134
|
return new Response(toBytes(result.webout), { status: 200, headers })
|
|
@@ -159,6 +174,123 @@ const readPayload = async (request) => {
|
|
|
159
174
|
return { params: body.params || {}, tables: body.tables || [], json: body }
|
|
160
175
|
}
|
|
161
176
|
|
|
177
|
+
// ---------------------------------------------------------------- requests
|
|
178
|
+
/** A request body as JSON, and {} when it is not JSON - a client that posts a
|
|
179
|
+
* form where the route wants JSON gets the missing-field answer rather than a
|
|
180
|
+
* parse error. */
|
|
181
|
+
const readJson = async (request) => {
|
|
182
|
+
try {
|
|
183
|
+
return (await request.json()) || {}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return {}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const badRequest = (message) => {
|
|
190
|
+
const error = new Error(message)
|
|
191
|
+
error.status = 400
|
|
192
|
+
return error
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const payloadTooLarge = (limit) => {
|
|
196
|
+
const error = new Error(
|
|
197
|
+
`The upload is larger than the browser host allows (${Math.round(
|
|
198
|
+
limit / (1024 * 1024)
|
|
199
|
+
)} MB).`
|
|
200
|
+
)
|
|
201
|
+
error.status = 413
|
|
202
|
+
return error
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The value a route needs, or the 400 the server answers with when it is absent.
|
|
207
|
+
* The message is Joi's, because that is what a client sees from the real API.
|
|
208
|
+
*/
|
|
209
|
+
const required = (value, name) => {
|
|
210
|
+
if (typeof value === 'string' && value.length) return value
|
|
211
|
+
throw badRequest(`"${name}" is required`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* sasjs/server refuses a file path with one of these extensions (filePathSchema
|
|
216
|
+
* in api/src/utils/validation.ts). Nothing is executable on a static host, but a
|
|
217
|
+
* client that would be refused on deploy should hear it here too.
|
|
218
|
+
*/
|
|
219
|
+
const BLOCKED_FILE = /\.(exe|sh|htaccess)$/i
|
|
220
|
+
|
|
221
|
+
const fileTarget = (value, name) => {
|
|
222
|
+
const given = required(value, name)
|
|
223
|
+
if (BLOCKED_FILE.test(given)) throw badRequest('Invalid file extension')
|
|
224
|
+
return given
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The bytes of an uploaded file, from the multipart field the API names. */
|
|
228
|
+
const uploadBytes = async (request) => {
|
|
229
|
+
const form = await request.formData().catch(() => null)
|
|
230
|
+
const upload = form && form.get('file')
|
|
231
|
+
if (!upload || typeof upload === 'string')
|
|
232
|
+
throw badRequest('"file" is not present.')
|
|
233
|
+
// refused from the File's size, before the body is read: a worker holds the
|
|
234
|
+
// whole payload in memory, so an oversized upload there is an out-of-memory
|
|
235
|
+
// failure rather than an answer a client can act on
|
|
236
|
+
if (typeof upload.size === 'number' && upload.size > R.MAX_UPLOAD_BYTES)
|
|
237
|
+
throw payloadTooLarge(R.MAX_UPLOAD_BYTES)
|
|
238
|
+
const target = form.get('filePath')
|
|
239
|
+
return {
|
|
240
|
+
bytes: new Uint8Array(await upload.arrayBuffer()),
|
|
241
|
+
filePath: target === null ? undefined : target
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The content type `sendFile` would answer with for this path - by extension,
|
|
247
|
+
* because that is what express does, and because a byte entry would otherwise be
|
|
248
|
+
* served as `application/octet-stream` however text-like it is.
|
|
249
|
+
*/
|
|
250
|
+
const CONTENT_TYPES = {
|
|
251
|
+
'.csv': 'text/csv',
|
|
252
|
+
'.json': 'application/json',
|
|
253
|
+
'.sas': 'text/plain',
|
|
254
|
+
'.txt': 'text/plain',
|
|
255
|
+
'.log': 'text/plain',
|
|
256
|
+
'.html': 'text/html',
|
|
257
|
+
'.htm': 'text/html',
|
|
258
|
+
'.js': 'text/javascript',
|
|
259
|
+
'.css': 'text/css',
|
|
260
|
+
'.zip': 'application/zip',
|
|
261
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
262
|
+
'.xls': 'application/vnd.ms-excel',
|
|
263
|
+
'.pdf': 'application/pdf',
|
|
264
|
+
'.png': 'image/png',
|
|
265
|
+
'.jpg': 'image/jpeg',
|
|
266
|
+
'.jpeg': 'image/jpeg',
|
|
267
|
+
'.gif': 'image/gif',
|
|
268
|
+
'.svg': 'image/svg+xml'
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const contentTypeFor = (filePath, binary) => {
|
|
272
|
+
const name = String(filePath || '').toLowerCase()
|
|
273
|
+
const dot = name.lastIndexOf('.')
|
|
274
|
+
const known = dot > 0 ? CONTENT_TYPES[name.slice(dot)] : undefined
|
|
275
|
+
if (known) return known
|
|
276
|
+
return binary ? 'application/octet-stream' : 'text/plain'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Runs one drive mutation and then hands the page the entries it changed. The
|
|
281
|
+
* worker can be terminated between requests, so a write is only durable once the
|
|
282
|
+
* page has put it in IndexedDB.
|
|
283
|
+
*
|
|
284
|
+
* Await it at the call site: a bare `return driveResponse(...)` hands the
|
|
285
|
+
* promise back through handle's try without running its catch, so a refused
|
|
286
|
+
* write would reach the page as an unhandled rejection instead of its status.
|
|
287
|
+
*/
|
|
288
|
+
const driveResponse = async (runner) => {
|
|
289
|
+
const result = runner()
|
|
290
|
+
await persistChanges()
|
|
291
|
+
return json(result)
|
|
292
|
+
}
|
|
293
|
+
|
|
162
294
|
const handle = async (request, url) => {
|
|
163
295
|
const path = url.pathname.replace(/\/+$/, '')
|
|
164
296
|
const query = {}
|
|
@@ -178,20 +310,77 @@ const handle = async (request, url) => {
|
|
|
178
310
|
}
|
|
179
311
|
|
|
180
312
|
if (path.endsWith('/SASjsApi/drive/folder')) {
|
|
181
|
-
|
|
313
|
+
if (request.method === 'GET')
|
|
314
|
+
return json(await R.list(query._folderPath || ''))
|
|
315
|
+
|
|
316
|
+
if (request.method === 'POST') {
|
|
317
|
+
const body = await readJson(request)
|
|
318
|
+
return await driveResponse(() =>
|
|
319
|
+
R.mkdir(required(body.folderPath, 'folderPath'))
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (request.method === 'DELETE') {
|
|
324
|
+
// an empty path names the drive root, which the server refuses at
|
|
325
|
+
// validation rather than deleting everything
|
|
326
|
+
const folderPath = required(query._folderPath, '_folderPath')
|
|
327
|
+
return await driveResponse(() => R.rmdir(folderPath))
|
|
328
|
+
}
|
|
182
329
|
}
|
|
183
330
|
|
|
184
331
|
if (path.endsWith('/SASjsApi/drive/filetree')) {
|
|
185
332
|
return json(R.tree())
|
|
186
333
|
}
|
|
187
334
|
|
|
188
|
-
if (path.endsWith('/SASjsApi/drive/
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
335
|
+
if (path.endsWith('/SASjsApi/drive/rename')) {
|
|
336
|
+
if (request.method === 'POST') {
|
|
337
|
+
const body = await readJson(request)
|
|
338
|
+
// both ends go through the extension check: without it a file could be
|
|
339
|
+
// renamed into a blocked name and then be unreachable by DELETE and PATCH,
|
|
340
|
+
// which apply the same check to the path they are given
|
|
341
|
+
const oldPath = fileTarget(body.oldPath, 'oldPath')
|
|
342
|
+
const newPath = fileTarget(body.newPath, 'newPath')
|
|
343
|
+
return await driveResponse(() => R.rename(oldPath, newPath))
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (path.endsWith('/SASjsApi/drive/file')) {
|
|
348
|
+
if (request.method === 'GET') {
|
|
349
|
+
const file = R.read(query._filePath || '')
|
|
350
|
+
// the server answers this with `sendFile`, so the type comes from the
|
|
351
|
+
// extension: a .csv is text/csv whether it was uploaded as bytes or
|
|
352
|
+
// written by a service as text
|
|
353
|
+
return new Response(
|
|
354
|
+
file.binary ? toBytes(file.contents) : file.contents,
|
|
355
|
+
{
|
|
356
|
+
status: 200,
|
|
357
|
+
headers: {
|
|
358
|
+
'content-type': contentTypeFor(query._filePath, file.binary)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (request.method === 'DELETE') {
|
|
365
|
+
const filePath = fileTarget(query._filePath, '_filePath')
|
|
366
|
+
return await driveResponse(() => R.remove(filePath))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// POST creates (409 when the path is taken) and PATCH replaces (404 when
|
|
370
|
+
// it is not there) - the split the server's routes make, so a client that
|
|
371
|
+
// keys on the status code behaves the same way
|
|
372
|
+
if (request.method === 'POST' || request.method === 'PATCH') {
|
|
373
|
+
const upload = await uploadBytes(request)
|
|
374
|
+
const filePath = fileTarget(
|
|
375
|
+
query._filePath || upload.filePath,
|
|
376
|
+
'_filePath'
|
|
377
|
+
)
|
|
378
|
+
return await driveResponse(() =>
|
|
379
|
+
request.method === 'PATCH'
|
|
380
|
+
? R.update(filePath, upload.bytes)
|
|
381
|
+
: R.save(filePath, upload.bytes)
|
|
382
|
+
)
|
|
383
|
+
}
|
|
195
384
|
}
|
|
196
385
|
|
|
197
386
|
if (
|
|
@@ -238,6 +427,12 @@ const handle = async (request, url) => {
|
|
|
238
427
|
} catch (e) {
|
|
239
428
|
const message = (e && e.stack) || String(e)
|
|
240
429
|
broadcast({ type: 'sasjs-host-error', path, message })
|
|
430
|
+
// A drive refusal is the message itself, in plain text: the server answers
|
|
431
|
+
// these with `res.status(code).send(err.message)`, and its own suite asserts
|
|
432
|
+
// `res.text`. The other routes answer JSON, because a program failure
|
|
433
|
+
// carries more than a message (`status`, `error`), so they keep the envelope.
|
|
434
|
+
if (path.includes('/SASjsApi/drive/') && e && e.status)
|
|
435
|
+
return text((e && e.message) || String(e), e.status)
|
|
241
436
|
return json(
|
|
242
437
|
{ message: (e && e.message) || String(e) },
|
|
243
438
|
(e && e.status) || 500
|