/* oxlint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ import { HttpStatusCode, HttpStatusCodeType } from '@peertube/peertube-models' import { buildAbsoluteFixturePath, getFileSize } from '@peertube/peertube-node-utils' import { expect } from 'chai' import got, { Response as GotResponse } from 'got' import { isAbsolute } from 'path' import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest, makeUploadRequest, unwrapBody, unwrapText } from '../requests/requests.js' import { pick } from '@peertube/peertube-core-utils' import { createReadStream } from 'fs' import type { PeerTubeServer } from '../server/server.js' export interface OverrideCommandOptions { token?: string expectedStatus?: HttpStatusCodeType } interface InternalCommonCommandOptions extends OverrideCommandOptions { // Default to server.url url?: string path: string // If we automatically send the server token if the token is not provided implicitToken: boolean defaultExpectedStatus: HttpStatusCodeType // Common optional request parameters contentType?: string accept?: string redirects?: number range?: string host?: string headers?: { [name: string]: string } requestType?: string responseType?: string xForwardedFor?: string query?: { [id: string]: any } rawQuery?: string } export abstract class AbstractCommand { constructor ( protected server: PeerTubeServer ) { } protected getRequestBody (options: InternalCommonCommandOptions) { return unwrapBody(this.getRequest(options)) } protected getRequestText (options: InternalCommonCommandOptions) { return unwrapText(this.getRequest(options)) } protected getRawRequest (options: Omit) { const { url, range } = options const { host, protocol, pathname } = new URL(url) return this.getRequest({ ...options, token: this.buildCommonRequestToken(options), defaultExpectedStatus: this.buildExpectedStatus(options), url: `${protocol}//${host}`, path: pathname, range }) } protected getRequest (options: InternalCommonCommandOptions) { return makeGetRequest(this.buildCommonRequestOptions(options)) } protected deleteRequest (options: InternalCommonCommandOptions) { return makeDeleteRequest(this.buildCommonRequestOptions(options)) } protected putBodyRequest ( options: InternalCommonCommandOptions & { fields?: { [fieldName: string]: any } headers?: { [name: string]: string } } ) { const { fields, headers } = options return makePutBodyRequest({ ...this.buildCommonRequestOptions(options), fields, headers }) } protected postBodyRequest ( options: InternalCommonCommandOptions & { fields?: { [fieldName: string]: any } headers?: { [name: string]: string } } ) { const { fields, headers } = options return makePostBodyRequest({ ...this.buildCommonRequestOptions(options), fields, headers }) } protected postUploadRequest ( options: InternalCommonCommandOptions & { fields?: { [fieldName: string]: any } attaches?: { [fieldName: string]: any } } ) { const { fields, attaches } = options return makeUploadRequest({ ...this.buildCommonRequestOptions(options), method: 'POST', fields, attaches }) } protected putUploadRequest ( options: InternalCommonCommandOptions & { fields?: { [fieldName: string]: any } attaches?: { [fieldName: string]: any } } ) { const { fields, attaches } = options return makeUploadRequest({ ...this.buildCommonRequestOptions(options), method: 'PUT', fields, attaches }) } protected updateImageRequest ( options: InternalCommonCommandOptions & { fixture: string fieldname: string } ) { const filePath = isAbsolute(options.fixture) ? options.fixture : buildAbsoluteFixturePath(options.fixture) return this.postUploadRequest({ ...options, fields: {}, attaches: { [options.fieldname]: filePath } }) } protected buildCommonRequestOptions (options: InternalCommonCommandOptions) { const { url, path, requestType } = options return { url: url ?? this.server.url, path, type: requestType, token: this.buildCommonRequestToken(options), expectedStatus: this.buildExpectedStatus(options), ...pick(options, [ 'redirects', 'contentType', 'range', 'host', 'accept', 'headers', 'responseType', 'xForwardedFor', 'query', 'rawQuery' ]) } } protected buildCommonRequestToken (options: Pick) { const { token } = options const fallbackToken = options.implicitToken ? this.server.accessToken : undefined return token !== undefined ? token : fallbackToken } protected buildExpectedStatus (options: Pick) { const { expectedStatus, defaultExpectedStatus } = options return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus } protected buildVideoPasswordHeader (videoPassword: string) { return videoPassword !== undefined && videoPassword !== null ? { 'x-peertube-video-password': videoPassword } : undefined } // --------------------------------------------------------------------------- protected async buildResumeUpload ( options: OverrideCommandOptions & { path: string fixture: string attaches?: Record fields?: Record completedExpectedStatus?: HttpStatusCodeType // When the upload is finished } ): Promise { const { path, fixture, expectedStatus = HttpStatusCode.OK_200, completedExpectedStatus } = options let size = 0 let videoFilePath: string let mimetype = 'video/mp4' if (fixture) { videoFilePath = buildAbsoluteFixturePath(fixture) size = await getFileSize(videoFilePath) if (videoFilePath.endsWith('.mkv')) { mimetype = 'video/x-matroska' } else if (videoFilePath.endsWith('.webm')) { mimetype = 'video/webm' } else if (videoFilePath.endsWith('.zip')) { mimetype = 'application/zip' } } // Do not check status automatically, we'll check it manually const initializeSessionRes = await this.prepareResumableUpload({ ...options, path, expectedStatus: null, size, mimetype }) const initStatus = initializeSessionRes.status if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) { if (expectedStatus !== HttpStatusCode.OK_200) { throw new Error( `Expected status code is not correct for resumable upload initialization. Expected ${expectedStatus} but got success` ) } const locationHeader = initializeSessionRes.header['location'] expect(locationHeader).to.not.be.undefined const pathUploadId = locationHeader.split('?')[1] const result = await this.sendResumableChunks({ ...options, path, pathUploadId, videoFilePath, size, expectedStatus: completedExpectedStatus }) if (result.statusCode === HttpStatusCode.OK_200) { await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, path, pathUploadId }) } return result.body as T } const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200 ? HttpStatusCode.CREATED_201 : expectedStatus expect(initStatus).to.equal(expectedInitStatus) return initializeSessionRes.body.video || initializeSessionRes.body } protected async prepareResumableUpload ( options: OverrideCommandOptions & { path: string fixture: string size: number mimetype: string attaches?: Record fields?: Record originalName?: string lastModified?: number } ) { const { path, attaches = {}, fields = {}, originalName, lastModified, fixture, size, mimetype } = options const uploadOptions = { ...options, path, headers: { 'X-Upload-Content-Type': mimetype, 'X-Upload-Content-Length': size.toString() }, fields: { filename: fixture, originalName, lastModified, ...fields }, // Fixture will be sent later attaches, implicitToken: true, defaultExpectedStatus: null } if (Object.keys(attaches).length === 0) return this.postBodyRequest(uploadOptions) return this.postUploadRequest(uploadOptions) } protected async sendResumableChunks ( options: OverrideCommandOptions & { pathUploadId: string path: string videoFilePath: string size: number contentLength?: number contentRangeBuilder?: (start: number, chunk: any) => string digestBuilder?: (chunk: any) => string } ) { const { path, pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, digestBuilder, expectedStatus = HttpStatusCode.OK_200 } = options let start = 0 const token = this.buildCommonRequestToken({ ...options, implicitToken: true }) const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) const server = this.server return new Promise>((resolve, reject) => { readable.on('data', async function onData (chunk) { try { readable.pause() const byterangeStart = start + chunk.length - 1 const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/octet-stream', 'Content-Range': contentRangeBuilder ? contentRangeBuilder(start, chunk) : `bytes ${start}-${byterangeStart}/${size}`, 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' } if (digestBuilder) { Object.assign(headers, { digest: digestBuilder(chunk) }) } const res = await got({ url: new URL(path + '?' + pathUploadId, server.url).toString(), method: 'put', headers, body: chunk, responseType: 'json', throwHttpErrors: false }) start += chunk.length // Last request, check final status if (byterangeStart + 1 === size) { if (res.statusCode === expectedStatus) { return resolve(res) } if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { readable.off('data', onData) // oxlint-disable-next-line max-len const message = `Incorrect transient behaviour sending intermediary chunks. Status code is ${res.statusCode} instead of ${expectedStatus}` return reject(new Error(message)) } } readable.resume() } catch (err) { reject(err) } }) }) } protected endResumableUpload ( options: OverrideCommandOptions & { path: string pathUploadId: string } ) { return this.deleteRequest({ ...options, path: options.path, rawQuery: options.pathUploadId, implicitToken: true, defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 }) } }