enso/tools/simple-library-server/main.js
2024-07-26 17:47:59 +10:00

247 lines
7.2 KiB
JavaScript
Executable File

#!/usr/bin/env node
const express = require('express')
const crypto = require('crypto')
const path = require('path')
const os = require('os')
const fs = require('fs')
const fsPromises = require('fs/promises')
const multer = require('multer')
const compression = require('compression')
const yargs = require('yargs')
const semverValid = require('semver/functions/valid')
const LOG_REQUESTS = false
const argv = yargs
.usage('$0', 'Allows to host Enso libraries and editions from the local filesystem through HTTP.')
.option('port', {
description: 'The port to listen on.',
type: 'number',
default: 8080,
})
.option('root', {
description:
'The root of the repository. It should contain a `libraries` or `editions` directory. See the documentation for more details.',
type: 'string',
default: '.',
})
.option('upload', {
description:
'Specifies whether to allow uploading libraries and which authentication model to choose.',
choices: ['disabled', 'no-auth', 'constant-token'],
default: 'disabled',
})
.help()
.alias('help', 'h').argv
const libraryRoot = path.join(argv.root, 'libraries')
const app = express()
if (LOG_REQUESTS) {
app.use((req, res, next) => {
console.log(`Received [${req.method}] ${req.originalUrl}`)
console.log(` Headers: ${JSON.stringify(req.headers)}`)
console.log(` Query: ${JSON.stringify(req.query)}`)
console.log(` Body: ${JSON.stringify(req.body)}`)
next()
})
}
const tmpDir = path.join(os.tmpdir(), 'enso-library-repo-uploads')
const upload = multer({ dest: tmpDir })
app.use(compression({ filter: shouldCompress }))
/** The token to compare against for simple authentication.
*
* If it is not set, no authentication checks are made.
*/
let token = null
if (argv.upload == 'disabled') {
console.log('Uploads are disabled.')
} else {
app.post('/upload', upload.any(), handleUpload)
if (argv.upload == 'constant-token') {
const envVar = 'ENSO_AUTH_TOKEN'
token = process.env[envVar]
if (!token) {
throw `${envVar} is not defined.`
} else {
console.log(`Checking the ${envVar} to authorize requests.`)
}
} else {
console.log('WARNING: Uploads are enabled without any authentication.')
}
}
app.get('/health', function (req, res) {
res.status(200).send('OK')
})
app.use(express.static(argv.root))
let port = argv.port
if (process.env.PORT) {
port = process.env.PORT
console.log(`Overriding the port to ${port} set by the PORT environment variable.`)
}
console.log(`Serving the repository located under ${argv.root} on port ${port}.`)
const server = app.listen(port)
function handleShutdown() {
console.log('Received a signal - shutting down.')
server.close(() => {
console.log('Server terminated.')
})
}
process.on('SIGTERM', handleShutdown)
process.on('SIGINT', handleShutdown)
/// Specifies if a particular file can be compressed in transfer, if supported.
function shouldCompress(req, res) {
if (req.path.endsWith('.yaml')) {
return true
}
return compression.filter(req, res)
}
/** Handles upload of a library. */
async function handleUpload(req, res) {
function fail(code, message) {
res.status(code).json({ error: message })
if (req.files !== undefined) {
cleanFiles(req.files)
}
}
if (token !== null) {
const userToken = req.get('Auth-Token')
if (userToken != token) {
return fail(403, 'Authorization failed.')
}
}
const version = req.query.version
const namespace = req.query.namespace
const name = req.query.name
if (version === undefined || namespace == undefined || name === undefined) {
return fail(400, 'One or more required fields were missing.')
}
if (!isVersionValid(version)) {
return fail(400, `Invalid semver version string [${version}].`)
}
if (!isNamespaceValid(namespace)) {
return fail(400, `Invalid username [${namespace}].`)
}
if (!isNameValid(name)) {
return fail(400, `Invalid library name [${name}].`)
}
for (var i = 0; i < req.files.length; ++i) {
const filename = req.files[i].originalname
if (!isFilenameValid(filename)) {
return fail(400, `Invalid filename: ${filename}.`)
}
}
const libraryBasePath = path.join(libraryRoot, namespace, name)
const libraryPath = path.join(libraryBasePath, version)
/** Finds a name for a temporary directory to move the files to,
so that the upload can then be committed atomically by renaming
a single directory. */
function findRandomTemporaryDirectory() {
const randomName = crypto.randomBytes(32).toString('hex')
const temporaryPath = path.join(libraryBasePath, randomName)
if (fs.existsSync(temporaryPath)) {
return findRandomTemporaryDirectory()
}
return temporaryPath
}
if (fs.existsSync(libraryPath)) {
return fail(
409,
'A library with the given name and version ' +
'combination already exists. Versions are immutable, so you must ' +
'bump the library version when uploading a newer version.',
)
}
const temporaryPath = findRandomTemporaryDirectory()
await fsPromises.mkdir(libraryBasePath, { recursive: true })
await fsPromises.mkdir(temporaryPath, { recursive: true })
console.log(`Uploading library [${namespace}.${name}:${version}].`)
try {
await putFiles(temporaryPath, req.files)
await fsPromises.rename(temporaryPath, libraryPath)
} catch (error) {
console.log(`Upload failed: [${error}].`)
console.error(error.stack)
return fail(500, 'Upload failed due to an internal error.')
}
console.log('Upload complete.')
res.status(200).json({ message: 'Successfully uploaded the library.' })
}
/// Checks if a version complies with the semver specification.
function isVersionValid(version) {
return semverValid(version) !== null
}
/// Checks if the namespace/username is valid.
function isNamespaceValid(namespace) {
return /^[A-Za-z][a-z0-9]*$/.test(namespace) && namespace.length >= 3
}
/** Checks if the library name is valid.
*
* It may actually accept more identifiers as valid than Enso would, the actual
* check should be done when creating the library. This is just a sanity check
* for safety.
*/
function isNameValid(name) {
return /^[A-Za-z0-9_]+$/.test(name)
}
// TODO [RW] for now slashes are not permitted to avoid attacks; later on at least the `meta` directory should be allowed, but not much besides that
/// Checks if the uploaded filename is valid.
function isFilenameValid(name) {
return /^[A-Za-z0-9][A-Za-z0-9\._\-]*$/.test(name)
}
/// Schedules to remove the files, if they still exist.
function cleanFiles(files) {
files.forEach(file => {
if (fs.existsSync(file.path)) {
fs.unlink(file.path, err => {
if (err) {
console.error(
`Failed to remove ${file.path} ($file.originalname) from a failed upload: ${err}.`,
)
}
})
}
})
}
/// Moves the files to the provided destination directory.
async function putFiles(directory, files) {
for (var i = 0; i < files.length; ++i) {
const file = files[i]
const filename = file.originalname
const destination = path.join(directory, filename)
await fsPromises.copyFile(file.path, destination)
await fsPromises.unlink(file.path)
}
}