diff --git a/apps/http-server/packages/generic/.gitignore b/apps/http-server/packages/generic/.gitignore new file mode 100644 index 00000000..ec96dd6b --- /dev/null +++ b/apps/http-server/packages/generic/.gitignore @@ -0,0 +1 @@ +src/packageVersion.ts diff --git a/apps/http-server/packages/generic/package.json b/apps/http-server/packages/generic/package.json index 6c737e09..9346c0a5 100644 --- a/apps/http-server/packages/generic/package.json +++ b/apps/http-server/packages/generic/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "yarn rimraf dist && yarn build:main", + "build": "yarn rimraf dist && node scripts/prebuild.js && yarn build:main", "build:main": "tsc -p tsconfig.json", "__test": "jest" }, diff --git a/apps/http-server/packages/generic/scripts/prebuild.js b/apps/http-server/packages/generic/scripts/prebuild.js new file mode 100644 index 00000000..8616d220 --- /dev/null +++ b/apps/http-server/packages/generic/scripts/prebuild.js @@ -0,0 +1,20 @@ +const fs = require('fs').promises + +async function main() { + const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8')) + const libStr = `// ****** This file is generated at build-time by scripts/prebuild.js ****** +/** + * The version of the package.json file + */ +export const PACKAGE_JSON_VERSION = '${packageJson.version}' +` + + await fs.writeFile('src/packageVersion.ts', libStr, 'utf8') +} + +main().catch((e) => { + // eslint-disable-next-line no-console + console.error(e) + // eslint-disable-next-line no-process-exit + process.exit(1) +}) diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index 0f0d73bf..8a94dcaa 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -8,10 +8,12 @@ import cors from '@koa/cors' import bodyParser from 'koa-bodyparser' import { HTTPServerConfig, LoggerInstance, stringifyError, first } from '@sofie-package-manager/api' -import { BadResponse, Storage } from './storage/storage' +import { BadResponse, PackageInfo, ResponseMeta, Storage, isBadResponse } from './storage/storage' import { FileStorage } from './storage/fileStorage' import { CTX, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' +// eslint-disable-next-line node/no-unpublished-import +import { PACKAGE_JSON_VERSION } from './packageVersion' const fsReadFile = promisify(fs.readFile) @@ -24,6 +26,8 @@ export class PackageProxyServer { private storage: Storage private logger: LoggerInstance + private startupTime = Date.now() + constructor(logger: LoggerInstance, private config: HTTPServerConfig) { this.logger = logger.category('PackageProxyServer') this.app.on('error', (err) => { @@ -86,13 +90,16 @@ export class PackageProxyServer { }) this.router.get('/packages', async (ctx) => { - await this.handleStorage(ctx, async () => this.storage.listPackages(ctx)) + await this.handleStorage(ctx, async () => this.storage.listPackages()) + }) + this.router.get('/list', async (ctx) => { + await this.handleStorageHTMLList(ctx, async () => this.storage.listPackages()) }) this.router.get('/package/:path+', async (ctx) => { - await this.handleStorage(ctx, async () => this.storage.getPackage(ctx.params.path, ctx)) + await this.handleStorage(ctx, async () => this.storage.getPackage(ctx.params.path)) }) this.router.head('/package/:path+', async (ctx) => { - await this.handleStorage(ctx, async () => this.storage.headPackage(ctx.params.path, ctx)) + await this.handleStorage(ctx, async () => this.storage.headPackage(ctx.params.path)) }) this.router.post('/package/:path+', async (ctx) => { this.logger.debug(`POST ${ctx.request.URL}`) @@ -118,22 +125,17 @@ export class PackageProxyServer { }) this.router.delete('/package/:path+', async (ctx) => { this.logger.debug(`DELETE ${ctx.request.URL}`) - await this.handleStorage(ctx, async () => this.storage.deletePackage(ctx.params.path, ctx)) + await this.handleStorage(ctx, async () => this.storage.deletePackage(ctx.params.path)) }) // Convenient pages: this.router.get('/', async (ctx) => { - let packageJson = { version: '0.0.0' } - try { - packageJson = JSON.parse( - await fsReadFile('../package.json', { - encoding: 'utf8', - }) - ) - } catch (err) { - // ignore + ctx.body = { + name: 'Package proxy server', + version: PACKAGE_JSON_VERSION, + uptime: Date.now() - this.startupTime, + info: this.storage.getInfo(), } - ctx.body = { name: 'Package proxy server', version: packageJson.version, info: this.storage.getInfo() } }) this.router.get('/uploadForm/:path+', async (ctx) => { // ctx.response.status = result.code @@ -165,12 +167,71 @@ export class PackageProxyServer { } }) } - private async handleStorage(ctx: CTX, storageFcn: () => Promise) { + private async handleStorage(ctx: CTX, storageFcn: () => Promise<{ meta: ResponseMeta; body?: any } | BadResponse>) { try { const result = await storageFcn() - if (result !== true) { + if (isBadResponse(result)) { ctx.response.status = result.code ctx.body = result.reason + } else { + ctx.response.status = result.meta.statusCode + if (result.meta.type !== undefined) ctx.type = result.meta.type + if (result.meta.length !== undefined) ctx.length = result.meta.length + if (result.meta.lastModified !== undefined) ctx.lastModified = result.meta.lastModified + + if (result.meta.headers) { + for (const [key, value] of Object.entries(result.meta.headers)) { + ctx.set(key, value) + } + } + + if (result.body) ctx.body = result.body + } + } catch (err) { + this.logger.error(`Error in handleStorage: ${stringifyError(err)} `) + ctx.response.status = 500 + ctx.body = 'Internal server error' + } + } + private async handleStorageHTMLList( + ctx: CTX, + storageFcn: () => Promise<{ body: { packages: PackageInfo[] } } | BadResponse> + ) { + try { + const result = await storageFcn() + if (isBadResponse(result)) { + ctx.response.status = result.code + ctx.body = result.reason + } else { + const packages = result.body.packages + + ctx.set('Content-Type', 'text/html') + ctx.body = ` + + + + + +

Packages

+ +${packages + .map( + (pkg) => + ` + + + + ` + ) + .join('')} +
${pkg.path}${pkg.size}${pkg.modified}
+ +` } } catch (err) { this.logger.error(`Error in handleStorage: ${stringifyError(err)} `) diff --git a/apps/http-server/packages/generic/src/storage/fileStorage.ts b/apps/http-server/packages/generic/src/storage/fileStorage.ts index db8f8996..d3b46e90 100644 --- a/apps/http-server/packages/generic/src/storage/fileStorage.ts +++ b/apps/http-server/packages/generic/src/storage/fileStorage.ts @@ -3,9 +3,9 @@ import path from 'path' import { promisify } from 'util' import mime from 'mime-types' import prettyBytes from 'pretty-bytes' -import { asyncPipe, CTX, CTXPost } from '../lib' +import { asyncPipe, CTXPost } from '../lib' import { HTTPServerConfig, LoggerInstance } from '@sofie-package-manager/api' -import { BadResponse, Storage } from './storage' +import { BadResponse, PackageInfo, ResponseMeta, Storage } from './storage' import { Readable } from 'stream' // Note: Explicit types here, due to that for some strange reason, promisify wont pass through the correct typings. @@ -39,19 +39,14 @@ export class FileStorage extends Storage { } getInfo(): string { - return this._basePath + return `basePath: "${this._basePath}", cleanFileAge: ${this.config.httpServer.cleanFileAge}` } async init(): Promise { await fsMkDir(this._basePath, { recursive: true }) } - async listPackages(ctx: CTX): Promise { - type PackageInfo = { - path: string - size: string - modified: string - } + async listPackages(): Promise<{ meta: ResponseMeta; body: { packages: PackageInfo[] } } | BadResponse> { const packages: PackageInfo[] = [] const getAllFiles = async (basePath: string, dirPath: string) => { @@ -84,9 +79,11 @@ export class FileStorage extends Storage { return 0 }) - ctx.body = { packages: packages } + const meta: ResponseMeta = { + statusCode: 200, + } - return true + return { meta, body: { packages } } } private async getFileInfo(paramPath: string): Promise< | { @@ -118,40 +115,40 @@ export class FileStorage extends Storage { lastModified: stat.mtime, } } - async headPackage(paramPath: string, ctx: CTX): Promise { + async headPackage(paramPath: string): Promise<{ meta: ResponseMeta } | BadResponse> { const fileInfo = await this.getFileInfo(paramPath) if (!fileInfo.found) { return { code: 404, reason: 'Package not found' } } - this.setHeaders(fileInfo, ctx) - - ctx.response.status = 204 - - ctx.body = undefined + const meta: ResponseMeta = { + statusCode: 204, + } + this.updateMetaWithFileInfo(meta, fileInfo) - return true + return { meta } } - async getPackage(paramPath: string, ctx: CTX): Promise { + async getPackage(paramPath: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> { const fileInfo = await this.getFileInfo(paramPath) if (!fileInfo.found) { return { code: 404, reason: 'Package not found' } } - - this.setHeaders(fileInfo, ctx) + const meta: ResponseMeta = { + statusCode: 200, + } + this.updateMetaWithFileInfo(meta, fileInfo) const readStream = fs.createReadStream(fileInfo.fullPath) - ctx.body = readStream - return true + return { meta, body: readStream } } async postPackage( paramPath: string, ctx: CTXPost, fileStreamOrText: string | Readable | undefined - ): Promise { + ): Promise<{ meta: ResponseMeta; body: any } | BadResponse> { const fullPath = path.join(this._basePath, paramPath) await fsMkDir(path.dirname(fullPath), { recursive: true }) @@ -164,25 +161,27 @@ export class FileStorage extends Storage { plainText = fileStreamOrText } + const meta: ResponseMeta = { + statusCode: 200, + } + if (plainText) { // store plain text into file await fsWriteFile(fullPath, plainText) - ctx.body = { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } - ctx.response.status = 201 - return true + meta.statusCode = 201 + return { meta, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } } else if (fileStreamOrText && typeof fileStreamOrText !== 'string') { const fileStream = fileStreamOrText await asyncPipe(fileStream, fs.createWriteStream(fullPath)) - ctx.body = { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } - ctx.response.status = 201 - return true + meta.statusCode = 201 + return { meta, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } } else { return { code: 400, reason: 'No files provided' } } } - async deletePackage(paramPath: string, ctx: CTXPost): Promise { + async deletePackage(paramPath: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> { const fullPath = path.join(this._basePath, paramPath) if (!(await this.exists(fullPath))) { @@ -191,8 +190,11 @@ export class FileStorage extends Storage { await fsUnlink(fullPath) - ctx.body = { message: `Deleted "${paramPath}"` } - return true + const meta: ResponseMeta = { + statusCode: 200, + } + + return { meta, body: { message: `Deleted "${paramPath}"` } } } private async exists(fullPath: string) { @@ -280,21 +282,23 @@ export class FileStorage extends Storage { * @param {CTX} ctx * @memberof FileStorage */ - private setHeaders(info: FileInfo, ctx: CTX) { - ctx.type = info.mimeType - ctx.length = info.length - ctx.lastModified = info.lastModified + private updateMetaWithFileInfo(meta: ResponseMeta, info: FileInfo): void { + meta.type = info.mimeType + meta.length = info.length + meta.lastModified = info.lastModified + + if (!meta.headers) meta.headers = {} // Check the config. 0 or -1 means it's disabled: if (this.config.httpServer.cleanFileAge >= 0) { - ctx.set( - 'Expires', - FileStorage.calculateExpiresTimestamp(info.lastModified, this.config.httpServer.cleanFileAge) + meta.headers['Expires'] = FileStorage.calculateExpiresTimestamp( + info.lastModified, + this.config.httpServer.cleanFileAge ) } } /** - * Calculate the expiration timestamp, given a starting Date point and timespan duration + * Calculate the expiration timestamp, given a starting Date point and time-span duration * * @private * @static diff --git a/apps/http-server/packages/generic/src/storage/storage.ts b/apps/http-server/packages/generic/src/storage/storage.ts index bbaee154..b63e8626 100644 --- a/apps/http-server/packages/generic/src/storage/storage.ts +++ b/apps/http-server/packages/generic/src/storage/storage.ts @@ -1,20 +1,51 @@ import { Readable } from 'stream' -import { CTX, CTXPost } from '../lib' +import { CTXPost } from '../lib' export abstract class Storage { abstract init(): Promise abstract getInfo(): string - abstract listPackages(ctx: CTX): Promise - abstract headPackage(path: string, ctx: CTX): Promise - abstract getPackage(path: string, ctx: CTX): Promise + abstract listPackages(): Promise< + | { + meta: ResponseMeta + body: { + packages: PackageInfo[] + } + } + | BadResponse + > + abstract headPackage(path: string): Promise<{ meta: ResponseMeta } | BadResponse> + abstract getPackage(path: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> abstract postPackage( path: string, ctx: CTXPost, fileStreamOrText: string | Readable | undefined - ): Promise - abstract deletePackage(path: string, ctx: CTXPost): Promise + ): Promise<{ meta: ResponseMeta; body: any } | BadResponse> + abstract deletePackage(path: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> } export interface BadResponse { code: number reason: string } +export function isBadResponse(v: unknown): v is BadResponse { + return ( + typeof v === 'object' && + typeof (v as BadResponse).code === 'number' && + typeof (v as BadResponse).reason === 'string' + ) +} + +export type PackageInfo = { + path: string + size: string + modified: string +} + +export interface ResponseMeta { + statusCode: number + type?: string + length?: number + lastModified?: Date + headers?: { + [key: string]: string + } +}