mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
247 lines
7.2 KiB
JavaScript
Executable File
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)
|
|
}
|
|
}
|