mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 18:01:38 +03:00
465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
/**
|
|
* @file A HTTP server middleware which handles routes normally proxied through to
|
|
* the Project Manager.
|
|
*/
|
|
import * as fsSync from 'node:fs'
|
|
import * as fs from 'node:fs/promises'
|
|
import * as http from 'node:http'
|
|
import * as path from 'node:path'
|
|
|
|
import * as tar from 'tar'
|
|
import * as yaml from 'yaml'
|
|
|
|
import * as common from 'enso-common'
|
|
import GLOBAL_CONFIG from 'enso-common/src/config.json' with { type: 'json' }
|
|
|
|
import * as projectManagement from './projectManagement'
|
|
|
|
// =================
|
|
// === Constants ===
|
|
// =================
|
|
|
|
const HTTP_STATUS_OK = 200
|
|
const HTTP_STATUS_BAD_REQUEST = 400
|
|
const HTTP_STATUS_NOT_FOUND = 404
|
|
const PROJECTS_ROOT_DIRECTORY = projectManagement.getProjectsDirectory()
|
|
|
|
// =============
|
|
// === Types ===
|
|
// =============
|
|
|
|
/** Details of a project. */
|
|
interface ProjectMetadata {
|
|
/** The name of the project. */
|
|
readonly name: string
|
|
/** The namespace of the project. */
|
|
readonly namespace: string
|
|
/** The project id. */
|
|
readonly id: string
|
|
/**
|
|
* The Enso Engine version to use for the project, represented by a semver version
|
|
* string.
|
|
*
|
|
* If the edition associated with the project could not be resolved, the
|
|
* engine version may be missing.
|
|
*/
|
|
readonly engineVersion?: string
|
|
/** The project creation time. */
|
|
readonly created: string
|
|
/** The last opened datetime. */
|
|
readonly lastOpened?: string
|
|
}
|
|
|
|
/** Attributes of a file or folder. */
|
|
interface Attributes {
|
|
readonly creationTime: string
|
|
readonly lastAccessTime: string
|
|
readonly lastModifiedTime: string
|
|
readonly byteSize: number
|
|
}
|
|
|
|
/** Metadata for an arbitrary file system entry. */
|
|
type FileSystemEntry = DirectoryEntry | FileEntry | ProjectEntry
|
|
|
|
/** The discriminator value for {@link FileSystemEntry}. */
|
|
export enum FileSystemEntryType {
|
|
DirectoryEntry = 'DirectoryEntry',
|
|
ProjectEntry = 'ProjectEntry',
|
|
FileEntry = 'FileEntry',
|
|
}
|
|
|
|
/** Metadata for a file. */
|
|
interface FileEntry {
|
|
readonly type: FileSystemEntryType.FileEntry
|
|
readonly path: string
|
|
readonly attributes: Attributes
|
|
}
|
|
|
|
/** Metadata for a directory. */
|
|
interface DirectoryEntry {
|
|
readonly type: FileSystemEntryType.DirectoryEntry
|
|
readonly path: string
|
|
readonly attributes: Attributes
|
|
}
|
|
|
|
/** Metadata for a project. */
|
|
interface ProjectEntry {
|
|
readonly type: FileSystemEntryType.ProjectEntry
|
|
readonly path: string
|
|
readonly metadata: ProjectMetadata
|
|
readonly attributes: Attributes
|
|
}
|
|
|
|
// ====================================
|
|
// === projectManagerShimMiddleware ===
|
|
// ====================================
|
|
|
|
/** A middleware that handles */
|
|
export default function projectManagerShimMiddleware(
|
|
request: http.IncomingMessage,
|
|
response: http.ServerResponse,
|
|
next: () => void,
|
|
) {
|
|
const requestUrl = request.url
|
|
const requestPath = requestUrl?.split('?')[0]?.split('#')[0]
|
|
if (requestUrl != null && requestUrl.startsWith('/api/project-manager/')) {
|
|
const actualUrl = new URL(
|
|
requestUrl.replace(/^\/api\/project-manager/, GLOBAL_CONFIG.projectManagerHttpEndpoint),
|
|
)
|
|
request.pipe(
|
|
http.request(
|
|
// `...actualUrl` does NOT work because `URL` properties are not enumerable.
|
|
{
|
|
headers: request.headers,
|
|
host: actualUrl.host,
|
|
hostname: actualUrl.hostname,
|
|
method: request.method,
|
|
path: actualUrl.pathname,
|
|
port: actualUrl.port,
|
|
protocol: actualUrl.protocol,
|
|
},
|
|
(actualResponse) => {
|
|
response.writeHead(
|
|
// This is SAFE. The documentation says:
|
|
// Only valid for response obtained from ClientRequest.
|
|
actualResponse.statusCode!,
|
|
actualResponse.statusMessage,
|
|
actualResponse.headers,
|
|
)
|
|
actualResponse.pipe(response, { end: true })
|
|
},
|
|
),
|
|
{ end: true },
|
|
)
|
|
} else if (request.method === 'POST') {
|
|
switch (requestPath) {
|
|
case '/api/upload-file': {
|
|
const url = new URL(`https://example.com/${requestUrl}`)
|
|
const fileName = url.searchParams.get('file_name')
|
|
const directory = url.searchParams.get('directory') ?? PROJECTS_ROOT_DIRECTORY
|
|
if (fileName == null) {
|
|
response
|
|
.writeHead(HTTP_STATUS_BAD_REQUEST, common.COOP_COEP_CORP_HEADERS)
|
|
.end('Request is missing search parameter `file_name`.')
|
|
} else {
|
|
const filePath = path.join(directory, fileName)
|
|
void fs
|
|
.writeFile(filePath, request)
|
|
.then(() => {
|
|
response
|
|
.writeHead(HTTP_STATUS_OK, [
|
|
['Content-Length', String(filePath.length)],
|
|
['Content-Type', 'text/plain'],
|
|
...common.COOP_COEP_CORP_HEADERS,
|
|
])
|
|
.end(filePath)
|
|
})
|
|
.catch((e) => {
|
|
console.error(e)
|
|
response.writeHead(HTTP_STATUS_BAD_REQUEST, common.COOP_COEP_CORP_HEADERS).end()
|
|
})
|
|
}
|
|
break
|
|
}
|
|
// This endpoint should only be used when accessing the app from the browser.
|
|
// When accessing the app from Electron, the file input event will have the
|
|
// full system path.
|
|
case '/api/upload-project': {
|
|
const url = new URL(`https://example.com/${requestUrl}`)
|
|
const directory = url.searchParams.get('directory')
|
|
const name = url.searchParams.get('name')
|
|
void projectManagement
|
|
.uploadBundle(request, directory, name)
|
|
.then((id) => {
|
|
response
|
|
.writeHead(HTTP_STATUS_OK, [
|
|
['Content-Length', String(id.length)],
|
|
['Content-Type', 'text/plain'],
|
|
...common.COOP_COEP_CORP_HEADERS,
|
|
])
|
|
.end(id)
|
|
})
|
|
.catch(() => {
|
|
response.writeHead(HTTP_STATUS_BAD_REQUEST, common.COOP_COEP_CORP_HEADERS).end()
|
|
})
|
|
break
|
|
}
|
|
case '/api/run-project-manager-command': {
|
|
const cliArguments: unknown = JSON.parse(
|
|
new URL(`https://example.com/${requestUrl}`).searchParams.get('cli-arguments') ?? '[]',
|
|
)
|
|
if (
|
|
!Array.isArray(cliArguments) ||
|
|
!cliArguments.every((item): item is string => typeof item === 'string')
|
|
) {
|
|
response
|
|
.writeHead(HTTP_STATUS_BAD_REQUEST, common.COOP_COEP_CORP_HEADERS)
|
|
.end('Command arguments must be an array of strings.')
|
|
} else {
|
|
void (async () => {
|
|
const toJSONRPCResult = (result: unknown) =>
|
|
JSON.stringify({ jsonrpc: '2.0', id: 0, result })
|
|
const toJSONRPCError = (message: string, data?: unknown) =>
|
|
JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 0,
|
|
error: { code: 0, message, ...(data != null ? { data } : {}) },
|
|
})
|
|
let result = toJSONRPCError(`Error running Project Manager command.`, {
|
|
command: cliArguments,
|
|
})
|
|
try {
|
|
switch (cliArguments[0]) {
|
|
case '--filesystem-exists': {
|
|
const directoryPath = cliArguments[1]
|
|
if (directoryPath != null) {
|
|
const exists = fsSync.existsSync(directoryPath)
|
|
result = toJSONRPCResult({ exists })
|
|
}
|
|
break
|
|
}
|
|
case '--filesystem-list': {
|
|
const directoryPath = cliArguments[1]
|
|
if (directoryPath != null) {
|
|
const entryNames = await fs.readdir(directoryPath)
|
|
const entries: FileSystemEntry[] = []
|
|
for (const entryName of entryNames) {
|
|
const entryPath = path.join(directoryPath, entryName)
|
|
if (isHidden(entryPath)) continue
|
|
const stat = await fs.stat(entryPath)
|
|
const attributes: Attributes = {
|
|
byteSize: stat.size,
|
|
creationTime: new Date(stat.ctimeMs).toISOString(),
|
|
lastAccessTime: new Date(stat.atimeMs).toISOString(),
|
|
lastModifiedTime: new Date(stat.mtimeMs).toISOString(),
|
|
}
|
|
if (stat.isFile()) {
|
|
entries.push({
|
|
type: FileSystemEntryType.FileEntry,
|
|
path: entryPath,
|
|
attributes,
|
|
} satisfies FileEntry)
|
|
} else {
|
|
try {
|
|
const packageMetadataPath = path.join(entryPath, 'package.yaml')
|
|
const projectMetadataPath = path.join(
|
|
entryPath,
|
|
projectManagement.PROJECT_METADATA_RELATIVE_PATH,
|
|
)
|
|
const packageMetadataContents = await fs.readFile(packageMetadataPath)
|
|
const projectMetadataContents = await fs.readFile(projectMetadataPath)
|
|
const metadata = extractProjectMetadata(
|
|
yaml.parse(packageMetadataContents.toString()),
|
|
JSON.parse(projectMetadataContents.toString()),
|
|
)
|
|
if (metadata != null) {
|
|
// This is a project.
|
|
entries.push({
|
|
type: FileSystemEntryType.ProjectEntry,
|
|
path: entryPath,
|
|
attributes,
|
|
metadata,
|
|
} satisfies ProjectEntry)
|
|
} else {
|
|
// This error moves control flow to the
|
|
// `catch` clause directly below.
|
|
throw new Error('Invalid project metadata.')
|
|
}
|
|
} catch {
|
|
// This is a regular directory, not a project.
|
|
entries.push({
|
|
type: FileSystemEntryType.DirectoryEntry,
|
|
path: entryPath,
|
|
attributes,
|
|
} satisfies DirectoryEntry)
|
|
}
|
|
}
|
|
}
|
|
result = toJSONRPCResult({ entries })
|
|
}
|
|
break
|
|
}
|
|
case '--filesystem-create-directory': {
|
|
const directoryPath = cliArguments[1]
|
|
if (directoryPath != null) {
|
|
await fs.mkdir(directoryPath, { recursive: true })
|
|
result = toJSONRPCResult(null)
|
|
}
|
|
break
|
|
}
|
|
case '--filesystem-write-path': {
|
|
const filePath = cliArguments[1]
|
|
if (filePath != null) {
|
|
await new Promise((resolve, reject) => {
|
|
request
|
|
.pipe(fsSync.createWriteStream(filePath), {
|
|
end: true,
|
|
})
|
|
.on('close', resolve)
|
|
.on('error', reject)
|
|
})
|
|
result = toJSONRPCResult(null)
|
|
}
|
|
break
|
|
}
|
|
case '--filesystem-move-from': {
|
|
const sourcePath = cliArguments[1]
|
|
const destinationPath = cliArguments[3]
|
|
if (
|
|
sourcePath != null &&
|
|
cliArguments[2] === '--filesystem-move-to' &&
|
|
destinationPath != null
|
|
) {
|
|
await fs.rename(sourcePath, destinationPath)
|
|
result = toJSONRPCResult(null)
|
|
}
|
|
break
|
|
}
|
|
case '--filesystem-delete': {
|
|
const fileOrDirectoryPath = cliArguments[1]
|
|
if (fileOrDirectoryPath != null) {
|
|
await fs.rm(fileOrDirectoryPath, { recursive: true })
|
|
result = toJSONRPCResult(null)
|
|
}
|
|
break
|
|
}
|
|
default: {
|
|
const message = `Error in Project Manager shim: unknown command ${JSON.stringify(cliArguments)}`
|
|
console.error(message)
|
|
result = toJSONRPCError(message)
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignored. `result` retains its original value indicating an error.
|
|
}
|
|
const buffer = Buffer.from(result)
|
|
response
|
|
.writeHead(HTTP_STATUS_OK, [
|
|
['Content-Length', String(buffer.byteLength)],
|
|
['Content-Type', 'application/json'],
|
|
...common.COOP_COEP_CORP_HEADERS,
|
|
])
|
|
.end(buffer)
|
|
})()
|
|
}
|
|
break
|
|
}
|
|
default: {
|
|
const downloadProjectMatch = requestPath?.match(
|
|
/^[/]api[/]project-manager[/]projects[/]([^/]+)[/]enso-project$/,
|
|
)
|
|
if (downloadProjectMatch) {
|
|
const uuid = downloadProjectMatch[1]
|
|
void fs.readdir(PROJECTS_ROOT_DIRECTORY).then(async (filenames) => {
|
|
let success = false
|
|
for (const filename of filenames) {
|
|
try {
|
|
const projectRoot = path.join(PROJECTS_ROOT_DIRECTORY, filename)
|
|
const stat = await fs.stat(projectRoot)
|
|
if (stat.isDirectory()) {
|
|
const metadataPath = path.join(
|
|
projectRoot,
|
|
projectManagement.PROJECT_METADATA_RELATIVE_PATH,
|
|
)
|
|
const metadataContents = await fs.readFile(metadataPath)
|
|
const metadata: unknown = JSON.parse(metadataContents.toString())
|
|
if (
|
|
typeof metadata === 'object' &&
|
|
metadata != null &&
|
|
'id' in metadata &&
|
|
metadata.id === uuid
|
|
) {
|
|
response.writeHead(HTTP_STATUS_OK, [
|
|
['Content-Type', 'application/gzip+x-enso-project'],
|
|
...common.COOP_COEP_CORP_HEADERS,
|
|
])
|
|
tar
|
|
.create({ gzip: true, cwd: projectRoot }, [projectRoot])
|
|
.pipe(response, { end: true })
|
|
success = true
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignored.
|
|
}
|
|
}
|
|
if (!success) {
|
|
response.writeHead(HTTP_STATUS_NOT_FOUND, common.COOP_COEP_CORP_HEADERS).end()
|
|
}
|
|
})
|
|
break
|
|
}
|
|
response.writeHead(HTTP_STATUS_NOT_FOUND, common.COOP_COEP_CORP_HEADERS).end()
|
|
break
|
|
}
|
|
}
|
|
} else if (request.method === 'GET' && requestPath === '/api/root-directory') {
|
|
response
|
|
.writeHead(HTTP_STATUS_OK, [
|
|
['Content-Length', String(PROJECTS_ROOT_DIRECTORY.length)],
|
|
['Content-Type', 'text/plain'],
|
|
...common.COOP_COEP_CORP_HEADERS,
|
|
])
|
|
.end(PROJECTS_ROOT_DIRECTORY)
|
|
} else {
|
|
next()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a {@link ProjectMetadata} if the metadata is a valid metadata object,
|
|
* else return `null`.
|
|
*/
|
|
function extractProjectMetadata(yamlObj: unknown, jsonObj: unknown): ProjectMetadata | null {
|
|
if (
|
|
typeof yamlObj !== 'object' ||
|
|
yamlObj == null ||
|
|
typeof jsonObj !== 'object' ||
|
|
jsonObj == null
|
|
) {
|
|
return null
|
|
} else {
|
|
const validDateString = (string: string) => {
|
|
const date = new Date(string)
|
|
return !Number.isNaN(Number(date)) ? date.toString() : null
|
|
}
|
|
const name = 'name' in yamlObj && typeof yamlObj.name === 'string' ? yamlObj.name : null
|
|
const namespace =
|
|
'namespace' in yamlObj && typeof yamlObj.namespace === 'string' ? yamlObj.namespace : 'local'
|
|
const engineVersion =
|
|
'edition' in yamlObj && typeof yamlObj.edition === 'string' ? yamlObj.edition : null
|
|
const id = 'id' in jsonObj && typeof jsonObj.id === 'string' ? jsonObj.id : null
|
|
const created =
|
|
'created' in jsonObj && typeof jsonObj.created === 'string' ?
|
|
validDateString(jsonObj.created)
|
|
: null
|
|
const lastOpened =
|
|
'lastOpened' in jsonObj && typeof jsonObj.lastOpened === 'string' ?
|
|
validDateString(jsonObj.lastOpened)
|
|
: null
|
|
if (name != null && id != null && created != null) {
|
|
return {
|
|
name,
|
|
namespace,
|
|
id,
|
|
...(engineVersion != null ? { engineVersion } : {}),
|
|
created,
|
|
...(lastOpened != null ? { lastOpened } : {}),
|
|
} satisfies ProjectMetadata
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if files that start with the dot.
|
|
* Note on Windows does not check the hidden property.
|
|
*/
|
|
function isHidden(filePath: string): boolean {
|
|
const dotfile = /(^|[\\/])\.[^\\/]+$/g
|
|
return dotfile.test(filePath)
|
|
}
|