From bf54587a3e2ad9c2c186828f2a5682b91ee2cc00 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 17 Dec 2021 09:29:23 +0100 Subject: shared/ typescript types dir server-commands --- shared/server-commands/bulk/bulk-command.ts | 20 + shared/server-commands/bulk/index.ts | 1 + shared/server-commands/cli/cli-command.ts | 27 + shared/server-commands/cli/index.ts | 1 + .../custom-pages/custom-pages-command.ts | 33 + shared/server-commands/custom-pages/index.ts | 1 + shared/server-commands/feeds/feeds-command.ts | 44 ++ shared/server-commands/feeds/index.ts | 1 + shared/server-commands/index.ts | 15 + shared/server-commands/logs/index.ts | 1 + shared/server-commands/logs/logs-command.ts | 44 ++ shared/server-commands/miscs/checks.ts | 58 ++ shared/server-commands/miscs/generate.ts | 75 ++ shared/server-commands/miscs/index.ts | 5 + shared/server-commands/miscs/sql-command.ts | 141 ++++ shared/server-commands/miscs/tests.ts | 101 +++ shared/server-commands/miscs/webtorrent.ts | 46 ++ shared/server-commands/mock-servers/index.ts | 5 + shared/server-commands/mock-servers/mock-429.ts | 33 + shared/server-commands/mock-servers/mock-email.ts | 63 ++ .../mock-servers/mock-instances-index.ts | 46 ++ .../mock-servers/mock-joinpeertube-versions.ts | 34 + .../mock-servers/mock-object-storage.ts | 41 ++ .../mock-servers/mock-plugin-blocklist.ts | 36 + shared/server-commands/mock-servers/mock-proxy.ts | 25 + shared/server-commands/mock-servers/utils.ts | 33 + .../server-commands/moderation/abuses-command.ts | 228 ++++++ shared/server-commands/moderation/index.ts | 1 + shared/server-commands/overviews/index.ts | 1 + .../server-commands/overviews/overviews-command.ts | 23 + .../server-commands/requests/check-api-params.ts | 48 ++ shared/server-commands/requests/index.ts | 3 + shared/server-commands/requests/requests.ts | 208 ++++++ shared/server-commands/search/index.ts | 1 + shared/server-commands/search/search-command.ts | 98 +++ shared/server-commands/server/config-command.ts | 353 +++++++++ .../server-commands/server/contact-form-command.ts | 31 + shared/server-commands/server/debug-command.ts | 33 + shared/server-commands/server/directories.ts | 34 + shared/server-commands/server/follows-command.ts | 139 ++++ shared/server-commands/server/follows.ts | 20 + shared/server-commands/server/index.ts | 17 + shared/server-commands/server/jobs-command.ts | 61 ++ shared/server-commands/server/jobs.ts | 84 +++ .../server/object-storage-command.ts | 77 ++ shared/server-commands/server/plugins-command.ts | 257 +++++++ shared/server-commands/server/plugins.ts | 18 + .../server-commands/server/redundancy-command.ts | 80 +++ shared/server-commands/server/server.ts | 392 ++++++++++ shared/server-commands/server/servers-command.ts | 92 +++ shared/server-commands/server/servers.ts | 49 ++ shared/server-commands/server/stats-command.ts | 25 + shared/server-commands/server/tracker.ts | 27 + shared/server-commands/shared/abstract-command.ts | 211 ++++++ shared/server-commands/shared/index.ts | 1 + shared/server-commands/socket/index.ts | 1 + shared/server-commands/socket/socket-io-command.ts | 15 + shared/server-commands/users/accounts-command.ts | 78 ++ shared/server-commands/users/actors.ts | 73 ++ shared/server-commands/users/blocklist-command.ts | 162 +++++ shared/server-commands/users/index.ts | 9 + shared/server-commands/users/login-command.ts | 132 ++++ shared/server-commands/users/login.ts | 19 + .../server-commands/users/notifications-command.ts | 86 +++ shared/server-commands/users/notifications.ts | 795 +++++++++++++++++++++ .../server-commands/users/subscriptions-command.ts | 99 +++ shared/server-commands/users/users-command.ts | 416 +++++++++++ shared/server-commands/videos/blacklist-command.ts | 76 ++ shared/server-commands/videos/captions-command.ts | 65 ++ shared/server-commands/videos/captions.ts | 21 + .../videos/change-ownership-command.ts | 68 ++ shared/server-commands/videos/channels-command.ts | 178 +++++ shared/server-commands/videos/channels.ts | 18 + shared/server-commands/videos/comments-command.ts | 152 ++++ shared/server-commands/videos/history-command.ts | 58 ++ shared/server-commands/videos/imports-command.ts | 47 ++ shared/server-commands/videos/index.ts | 19 + shared/server-commands/videos/live-command.ts | 155 ++++ shared/server-commands/videos/live.ts | 137 ++++ shared/server-commands/videos/playlists-command.ts | 280 ++++++++ shared/server-commands/videos/playlists.ts | 25 + shared/server-commands/videos/services-command.ts | 29 + .../videos/streaming-playlists-command.ts | 44 ++ .../server-commands/videos/streaming-playlists.ts | 77 ++ shared/server-commands/videos/videos-command.ts | 679 ++++++++++++++++++ shared/server-commands/videos/videos.ts | 104 +++ 86 files changed, 7659 insertions(+) create mode 100644 shared/server-commands/bulk/bulk-command.ts create mode 100644 shared/server-commands/bulk/index.ts create mode 100644 shared/server-commands/cli/cli-command.ts create mode 100644 shared/server-commands/cli/index.ts create mode 100644 shared/server-commands/custom-pages/custom-pages-command.ts create mode 100644 shared/server-commands/custom-pages/index.ts create mode 100644 shared/server-commands/feeds/feeds-command.ts create mode 100644 shared/server-commands/feeds/index.ts create mode 100644 shared/server-commands/index.ts create mode 100644 shared/server-commands/logs/index.ts create mode 100644 shared/server-commands/logs/logs-command.ts create mode 100644 shared/server-commands/miscs/checks.ts create mode 100644 shared/server-commands/miscs/generate.ts create mode 100644 shared/server-commands/miscs/index.ts create mode 100644 shared/server-commands/miscs/sql-command.ts create mode 100644 shared/server-commands/miscs/tests.ts create mode 100644 shared/server-commands/miscs/webtorrent.ts create mode 100644 shared/server-commands/mock-servers/index.ts create mode 100644 shared/server-commands/mock-servers/mock-429.ts create mode 100644 shared/server-commands/mock-servers/mock-email.ts create mode 100644 shared/server-commands/mock-servers/mock-instances-index.ts create mode 100644 shared/server-commands/mock-servers/mock-joinpeertube-versions.ts create mode 100644 shared/server-commands/mock-servers/mock-object-storage.ts create mode 100644 shared/server-commands/mock-servers/mock-plugin-blocklist.ts create mode 100644 shared/server-commands/mock-servers/mock-proxy.ts create mode 100644 shared/server-commands/mock-servers/utils.ts create mode 100644 shared/server-commands/moderation/abuses-command.ts create mode 100644 shared/server-commands/moderation/index.ts create mode 100644 shared/server-commands/overviews/index.ts create mode 100644 shared/server-commands/overviews/overviews-command.ts create mode 100644 shared/server-commands/requests/check-api-params.ts create mode 100644 shared/server-commands/requests/index.ts create mode 100644 shared/server-commands/requests/requests.ts create mode 100644 shared/server-commands/search/index.ts create mode 100644 shared/server-commands/search/search-command.ts create mode 100644 shared/server-commands/server/config-command.ts create mode 100644 shared/server-commands/server/contact-form-command.ts create mode 100644 shared/server-commands/server/debug-command.ts create mode 100644 shared/server-commands/server/directories.ts create mode 100644 shared/server-commands/server/follows-command.ts create mode 100644 shared/server-commands/server/follows.ts create mode 100644 shared/server-commands/server/index.ts create mode 100644 shared/server-commands/server/jobs-command.ts create mode 100644 shared/server-commands/server/jobs.ts create mode 100644 shared/server-commands/server/object-storage-command.ts create mode 100644 shared/server-commands/server/plugins-command.ts create mode 100644 shared/server-commands/server/plugins.ts create mode 100644 shared/server-commands/server/redundancy-command.ts create mode 100644 shared/server-commands/server/server.ts create mode 100644 shared/server-commands/server/servers-command.ts create mode 100644 shared/server-commands/server/servers.ts create mode 100644 shared/server-commands/server/stats-command.ts create mode 100644 shared/server-commands/server/tracker.ts create mode 100644 shared/server-commands/shared/abstract-command.ts create mode 100644 shared/server-commands/shared/index.ts create mode 100644 shared/server-commands/socket/index.ts create mode 100644 shared/server-commands/socket/socket-io-command.ts create mode 100644 shared/server-commands/users/accounts-command.ts create mode 100644 shared/server-commands/users/actors.ts create mode 100644 shared/server-commands/users/blocklist-command.ts create mode 100644 shared/server-commands/users/index.ts create mode 100644 shared/server-commands/users/login-command.ts create mode 100644 shared/server-commands/users/login.ts create mode 100644 shared/server-commands/users/notifications-command.ts create mode 100644 shared/server-commands/users/notifications.ts create mode 100644 shared/server-commands/users/subscriptions-command.ts create mode 100644 shared/server-commands/users/users-command.ts create mode 100644 shared/server-commands/videos/blacklist-command.ts create mode 100644 shared/server-commands/videos/captions-command.ts create mode 100644 shared/server-commands/videos/captions.ts create mode 100644 shared/server-commands/videos/change-ownership-command.ts create mode 100644 shared/server-commands/videos/channels-command.ts create mode 100644 shared/server-commands/videos/channels.ts create mode 100644 shared/server-commands/videos/comments-command.ts create mode 100644 shared/server-commands/videos/history-command.ts create mode 100644 shared/server-commands/videos/imports-command.ts create mode 100644 shared/server-commands/videos/index.ts create mode 100644 shared/server-commands/videos/live-command.ts create mode 100644 shared/server-commands/videos/live.ts create mode 100644 shared/server-commands/videos/playlists-command.ts create mode 100644 shared/server-commands/videos/playlists.ts create mode 100644 shared/server-commands/videos/services-command.ts create mode 100644 shared/server-commands/videos/streaming-playlists-command.ts create mode 100644 shared/server-commands/videos/streaming-playlists.ts create mode 100644 shared/server-commands/videos/videos-command.ts create mode 100644 shared/server-commands/videos/videos.ts (limited to 'shared/server-commands') diff --git a/shared/server-commands/bulk/bulk-command.ts b/shared/server-commands/bulk/bulk-command.ts new file mode 100644 index 000000000..b5c5673ce --- /dev/null +++ b/shared/server-commands/bulk/bulk-command.ts @@ -0,0 +1,20 @@ +import { BulkRemoveCommentsOfBody, HttpStatusCode } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class BulkCommand extends AbstractCommand { + + removeCommentsOf (options: OverrideCommandOptions & { + attributes: BulkRemoveCommentsOfBody + }) { + const { attributes } = options + + return this.postBodyRequest({ + ...options, + + path: '/api/v1/bulk/remove-comments-of', + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/bulk/index.ts b/shared/server-commands/bulk/index.ts new file mode 100644 index 000000000..391597243 --- /dev/null +++ b/shared/server-commands/bulk/index.ts @@ -0,0 +1 @@ +export * from './bulk-command' diff --git a/shared/server-commands/cli/cli-command.ts b/shared/server-commands/cli/cli-command.ts new file mode 100644 index 000000000..ab9738174 --- /dev/null +++ b/shared/server-commands/cli/cli-command.ts @@ -0,0 +1,27 @@ +import { exec } from 'child_process' +import { AbstractCommand } from '../shared' + +export class CLICommand extends AbstractCommand { + + static exec (command: string) { + return new Promise((res, rej) => { + exec(command, (err, stdout, _stderr) => { + if (err) return rej(err) + + return res(stdout) + }) + }) + } + + getEnv () { + return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber}` + } + + async execWithEnv (command: string, configOverride?: any) { + const prefix = configOverride + ? `NODE_CONFIG='${JSON.stringify(configOverride)}'` + : '' + + return CLICommand.exec(`${prefix} ${this.getEnv()} ${command}`) + } +} diff --git a/shared/server-commands/cli/index.ts b/shared/server-commands/cli/index.ts new file mode 100644 index 000000000..91b5abfbe --- /dev/null +++ b/shared/server-commands/cli/index.ts @@ -0,0 +1 @@ +export * from './cli-command' diff --git a/shared/server-commands/custom-pages/custom-pages-command.ts b/shared/server-commands/custom-pages/custom-pages-command.ts new file mode 100644 index 000000000..cd869a8de --- /dev/null +++ b/shared/server-commands/custom-pages/custom-pages-command.ts @@ -0,0 +1,33 @@ +import { CustomPage, HttpStatusCode } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class CustomPagesCommand extends AbstractCommand { + + getInstanceHomepage (options: OverrideCommandOptions = {}) { + const path = '/api/v1/custom-pages/homepage/instance' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateInstanceHomepage (options: OverrideCommandOptions & { + content: string + }) { + const { content } = options + const path = '/api/v1/custom-pages/homepage/instance' + + return this.putBodyRequest({ + ...options, + + path, + fields: { content }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/custom-pages/index.ts b/shared/server-commands/custom-pages/index.ts new file mode 100644 index 000000000..58aed04f2 --- /dev/null +++ b/shared/server-commands/custom-pages/index.ts @@ -0,0 +1 @@ +export * from './custom-pages-command' diff --git a/shared/server-commands/feeds/feeds-command.ts b/shared/server-commands/feeds/feeds-command.ts new file mode 100644 index 000000000..3c95f9536 --- /dev/null +++ b/shared/server-commands/feeds/feeds-command.ts @@ -0,0 +1,44 @@ + +import { HttpStatusCode } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +type FeedType = 'videos' | 'video-comments' | 'subscriptions' + +export class FeedCommand extends AbstractCommand { + + getXML (options: OverrideCommandOptions & { + feed: FeedType + format?: string + }) { + const { feed, format } = options + const path = '/feeds/' + feed + '.xml' + + return this.getRequestText({ + ...options, + + path, + query: format ? { format } : undefined, + accept: 'application/xml', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getJSON (options: OverrideCommandOptions & { + feed: FeedType + query?: { [ id: string ]: any } + }) { + const { feed, query } = options + const path = '/feeds/' + feed + '.json' + + return this.getRequestText({ + ...options, + + path, + query, + accept: 'application/json', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/feeds/index.ts b/shared/server-commands/feeds/index.ts new file mode 100644 index 000000000..662a22b6f --- /dev/null +++ b/shared/server-commands/feeds/index.ts @@ -0,0 +1 @@ +export * from './feeds-command' diff --git a/shared/server-commands/index.ts b/shared/server-commands/index.ts new file mode 100644 index 000000000..4b3636d06 --- /dev/null +++ b/shared/server-commands/index.ts @@ -0,0 +1,15 @@ +export * from './bulk' +export * from './cli' +export * from './custom-pages' +export * from './feeds' +export * from './logs' +export * from './miscs' +export * from './mock-servers' +export * from './moderation' +export * from './overviews' +export * from './requests' +export * from './search' +export * from './server' +export * from './socket' +export * from './users' +export * from './videos' diff --git a/shared/server-commands/logs/index.ts b/shared/server-commands/logs/index.ts new file mode 100644 index 000000000..69452d7f0 --- /dev/null +++ b/shared/server-commands/logs/index.ts @@ -0,0 +1 @@ +export * from './logs-command' diff --git a/shared/server-commands/logs/logs-command.ts b/shared/server-commands/logs/logs-command.ts new file mode 100644 index 000000000..7b5c66c0c --- /dev/null +++ b/shared/server-commands/logs/logs-command.ts @@ -0,0 +1,44 @@ +import { HttpStatusCode } from '@shared/models' +import { LogLevel } from '../../models/server/log-level.type' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class LogsCommand extends AbstractCommand { + + getLogs (options: OverrideCommandOptions & { + startDate: Date + endDate?: Date + level?: LogLevel + tagsOneOf?: string[] + }) { + const { startDate, endDate, tagsOneOf, level } = options + const path = '/api/v1/server/logs' + + return this.getRequestBody({ + ...options, + + path, + query: { startDate, endDate, level, tagsOneOf }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getAuditLogs (options: OverrideCommandOptions & { + startDate: Date + endDate?: Date + }) { + const { startDate, endDate } = options + + const path = '/api/v1/server/audit-logs' + + return this.getRequestBody({ + ...options, + + path, + query: { startDate, endDate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/shared/server-commands/miscs/checks.ts b/shared/server-commands/miscs/checks.ts new file mode 100644 index 000000000..589928997 --- /dev/null +++ b/shared/server-commands/miscs/checks.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { pathExists, readFile } from 'fs-extra' +import { join } from 'path' +import { root } from '@shared/core-utils' +import { HttpStatusCode } from '@shared/models' +import { makeGetRequest } from '../requests' +import { PeerTubeServer } from '../server' + +// Default interval -> 5 minutes +function dateIsValid (dateString: string, interval = 300000) { + const dateToCheck = new Date(dateString) + const now = new Date() + + return Math.abs(now.getTime() - dateToCheck.getTime()) <= interval +} + +function expectStartWith (str: string, start: string) { + expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.true +} + +async function expectLogDoesNotContain (server: PeerTubeServer, str: string) { + const content = await server.servers.getLogContent() + + expect(content.toString()).to.not.contain(str) +} + +async function testImage (url: string, imageName: string, imagePath: string, extension = '.jpg') { + const res = await makeGetRequest({ + url, + path: imagePath, + expectedStatus: HttpStatusCode.OK_200 + }) + + const body = res.body + + const data = await readFile(join(root(), 'server', 'tests', 'fixtures', imageName + extension)) + const minLength = body.length - ((30 * body.length) / 100) + const maxLength = body.length + ((30 * body.length) / 100) + + expect(data.length).to.be.above(minLength, 'the generated image is way smaller than the recorded fixture') + expect(data.length).to.be.below(maxLength, 'the generated image is way larger than the recorded fixture') +} + +async function testFileExistsOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) { + const base = server.servers.buildDirectory(directory) + + expect(await pathExists(join(base, filePath))).to.equal(exist) +} + +export { + dateIsValid, + testImage, + expectLogDoesNotContain, + testFileExistsOrNot, + expectStartWith +} diff --git a/shared/server-commands/miscs/generate.ts b/shared/server-commands/miscs/generate.ts new file mode 100644 index 000000000..93673a063 --- /dev/null +++ b/shared/server-commands/miscs/generate.ts @@ -0,0 +1,75 @@ +import { expect } from 'chai' +import ffmpeg from 'fluent-ffmpeg' +import { ensureDir, pathExists } from 'fs-extra' +import { dirname } from 'path' +import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils/ffprobe' +import { getMaxBitrate } from '@shared/core-utils' +import { buildAbsoluteFixturePath } from './tests' + +async function ensureHasTooBigBitrate (fixturePath: string) { + const bitrate = await getVideoFileBitrate(fixturePath) + const dataResolution = await getVideoFileResolution(fixturePath) + const fps = await getVideoFileFPS(fixturePath) + + const maxBitrate = getMaxBitrate({ ...dataResolution, fps }) + expect(bitrate).to.be.above(maxBitrate) +} + +async function generateHighBitrateVideo () { + const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4', true) + + await ensureDir(dirname(tempFixturePath)) + + const exists = await pathExists(tempFixturePath) + if (!exists) { + console.log('Generating high bitrate video.') + + // Generate a random, high bitrate video on the fly, so we don't have to include + // a large file in the repo. The video needs to have a certain minimum length so + // that FFmpeg properly applies bitrate limits. + // https://stackoverflow.com/a/15795112 + return new Promise((res, rej) => { + ffmpeg() + .outputOptions([ '-f rawvideo', '-video_size 1920x1080', '-i /dev/urandom' ]) + .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) + .outputOptions([ '-maxrate 10M', '-bufsize 10M' ]) + .output(tempFixturePath) + .on('error', rej) + .on('end', () => res(tempFixturePath)) + .run() + }) + } + + await ensureHasTooBigBitrate(tempFixturePath) + + return tempFixturePath +} + +async function generateVideoWithFramerate (fps = 60) { + const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true) + + await ensureDir(dirname(tempFixturePath)) + + const exists = await pathExists(tempFixturePath) + if (!exists) { + console.log('Generating video with framerate %d.', fps) + + return new Promise((res, rej) => { + ffmpeg() + .outputOptions([ '-f rawvideo', '-video_size 1280x720', '-i /dev/urandom' ]) + .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) + .outputOptions([ `-r ${fps}` ]) + .output(tempFixturePath) + .on('error', rej) + .on('end', () => res(tempFixturePath)) + .run() + }) + } + + return tempFixturePath +} + +export { + generateHighBitrateVideo, + generateVideoWithFramerate +} diff --git a/shared/server-commands/miscs/index.ts b/shared/server-commands/miscs/index.ts new file mode 100644 index 000000000..4474661de --- /dev/null +++ b/shared/server-commands/miscs/index.ts @@ -0,0 +1,5 @@ +export * from './checks' +export * from './generate' +export * from './sql-command' +export * from './tests' +export * from './webtorrent' diff --git a/shared/server-commands/miscs/sql-command.ts b/shared/server-commands/miscs/sql-command.ts new file mode 100644 index 000000000..bedb3349b --- /dev/null +++ b/shared/server-commands/miscs/sql-command.ts @@ -0,0 +1,141 @@ +import { QueryTypes, Sequelize } from 'sequelize' +import { AbstractCommand } from '../shared/abstract-command' + +export class SQLCommand extends AbstractCommand { + private sequelize: Sequelize + + deleteAll (table: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.DELETE } + + return seq.query(`DELETE FROM "${table}"`, options) + } + + async getCount (table: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.SELECT as QueryTypes.SELECT } + + const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options) + if (total === null) return 0 + + return parseInt(total, 10) + } + + setActorField (to: string, field: string, value: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.UPDATE } + + return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options) + } + + setVideoField (uuid: string, field: string, value: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.UPDATE } + + return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options) + } + + setPlaylistField (uuid: string, field: string, value: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.UPDATE } + + return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options) + } + + async countVideoViewsOf (uuid: string) { + const seq = this.getSequelize() + + const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' + + `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'` + + const options = { type: QueryTypes.SELECT as QueryTypes.SELECT } + const [ { total } ] = await seq.query<{ total: number }>(query, options) + + if (!total) return 0 + + return parseInt(total + '', 10) + } + + getActorImage (filename: string) { + return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`) + .then(rows => rows[0]) + } + + selectQuery (query: string) { + const seq = this.getSequelize() + const options = { type: QueryTypes.SELECT as QueryTypes.SELECT } + + return seq.query(query, options) + } + + updateQuery (query: string) { + const seq = this.getSequelize() + const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE } + + return seq.query(query, options) + } + + setPluginField (pluginName: string, field: string, value: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.UPDATE } + + return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options) + } + + setPluginVersion (pluginName: string, newVersion: string) { + return this.setPluginField(pluginName, 'version', newVersion) + } + + setPluginLatestVersion (pluginName: string, newVersion: string) { + return this.setPluginField(pluginName, 'latestVersion', newVersion) + } + + setActorFollowScores (newScore: number) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.UPDATE } + + return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options) + } + + setTokenField (accessToken: string, field: string, value: string) { + const seq = this.getSequelize() + + const options = { type: QueryTypes.UPDATE } + + return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options) + } + + async cleanup () { + if (!this.sequelize) return + + await this.sequelize.close() + this.sequelize = undefined + } + + private getSequelize () { + if (this.sequelize) return this.sequelize + + const dbname = 'peertube_test' + this.server.internalServerNumber + const username = 'peertube' + const password = 'peertube' + const host = 'localhost' + const port = 5432 + + this.sequelize = new Sequelize(dbname, username, password, { + dialect: 'postgres', + host, + port, + logging: false + }) + + return this.sequelize + } + +} diff --git a/shared/server-commands/miscs/tests.ts b/shared/server-commands/miscs/tests.ts new file mode 100644 index 000000000..658fe5fd3 --- /dev/null +++ b/shared/server-commands/miscs/tests.ts @@ -0,0 +1,101 @@ +import { stat } from 'fs-extra' +import { basename, isAbsolute, join, resolve } from 'path' + +const FIXTURE_URLS = { + peertube_long: 'https://peertube2.cpy.re/videos/watch/122d093a-1ede-43bd-bd34-59d2931ffc5e', + peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd', + + youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM', + + /** + * The video is used to check format-selection correctness wrt. HDR, + * which brings its own set of oddities outside of a MediaSource. + * + * The video needs to have the following format_ids: + * (which you can check by using `youtube-dl -F`): + * - (webm vp9) + * - (mp4 avc1) + * - (webm vp9.2 HDR) + */ + youtubeHDR: 'https://www.youtube.com/watch?v=RQgnBB9z_N4', + + // eslint-disable-next-line max-len + magnet: 'magnet:?xs=https%3A%2F%2Fpeertube2.cpy.re%2Flazy-static%2Ftorrents%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.torrent&xt=urn:btih:0f498834733e8057ed5c6f2ee2b4efd8d84a76ee&dn=super+peertube2+video&tr=https%3A%2F%2Fpeertube2.cpy.re%2Ftracker%2Fannounce&tr=wss%3A%2F%2Fpeertube2.cpy.re%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Fpeertube2.cpy.re%2Fstatic%2Fwebseed%2Fb209ca00-c8bb-4b2b-b421-1ede169f3dbc-720.mp4', + + badVideo: 'https://download.cpy.re/peertube/bad_video.mp4', + goodVideo: 'https://download.cpy.re/peertube/good_video.mp4', + goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4', + + file4K: 'https://download.cpy.re/peertube/4k_file.txt' +} + +function parallelTests () { + return process.env.MOCHA_PARALLEL === 'true' +} + +function isGithubCI () { + return !!process.env.GITHUB_WORKSPACE +} + +function areHttpImportTestsDisabled () { + const disabled = process.env.DISABLE_HTTP_IMPORT_TESTS === 'true' + + if (disabled) console.log('DISABLE_HTTP_IMPORT_TESTS env set to "true" so import tests are disabled') + + return disabled +} + +function areObjectStorageTestsDisabled () { + const disabled = process.env.ENABLE_OBJECT_STORAGE_TESTS !== 'true' + + if (disabled) console.log('ENABLE_OBJECT_STORAGE_TESTS env is not set to "true" so object storage tests are disabled') + + return disabled +} + +function buildAbsoluteFixturePath (path: string, customCIPath = false) { + if (isAbsolute(path)) return path + + if (customCIPath && process.env.GITHUB_WORKSPACE) { + return join(process.env.GITHUB_WORKSPACE, 'fixtures', path) + } + + return join(root(), 'server', 'tests', 'fixtures', path) +} + +function root () { + // We are in /miscs + let root = join(__dirname, '..', '..', '..') + + if (basename(root) === 'dist') root = resolve(root, '..') + + return root +} + +function wait (milliseconds: number) { + return new Promise(resolve => setTimeout(resolve, milliseconds)) +} + +async function getFileSize (path: string) { + const stats = await stat(path) + + return stats.size +} + +function buildRequestStub (): any { + return { } +} + +export { + FIXTURE_URLS, + + parallelTests, + isGithubCI, + areHttpImportTestsDisabled, + buildAbsoluteFixturePath, + getFileSize, + buildRequestStub, + areObjectStorageTestsDisabled, + wait, + root +} diff --git a/shared/server-commands/miscs/webtorrent.ts b/shared/server-commands/miscs/webtorrent.ts new file mode 100644 index 000000000..0683f8893 --- /dev/null +++ b/shared/server-commands/miscs/webtorrent.ts @@ -0,0 +1,46 @@ +import { readFile } from 'fs-extra' +import parseTorrent from 'parse-torrent' +import { basename, join } from 'path' +import * as WebTorrent from 'webtorrent' +import { VideoFile } from '@shared/models' +import { PeerTubeServer } from '../server' + +let webtorrent: WebTorrent.Instance + +function webtorrentAdd (torrentId: string, refreshWebTorrent = false) { + const WebTorrent = require('webtorrent') + + if (webtorrent && refreshWebTorrent) webtorrent.destroy() + if (!webtorrent || refreshWebTorrent) webtorrent = new WebTorrent() + + webtorrent.on('error', err => console.error('Error in webtorrent', err)) + + return new Promise(res => { + const torrent = webtorrent.add(torrentId, res) + + torrent.on('error', err => console.error('Error in webtorrent torrent', err)) + torrent.on('warning', warn => { + const msg = typeof warn === 'string' + ? warn + : warn.message + + if (msg.includes('Unsupported')) return + + console.error('Warning in webtorrent torrent', warn) + }) + }) +} + +async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile) { + const torrentName = basename(file.torrentUrl) + const torrentPath = server.servers.buildDirectory(join('torrents', torrentName)) + + const data = await readFile(torrentPath) + + return parseTorrent(data) +} + +export { + webtorrentAdd, + parseTorrentVideo +} diff --git a/shared/server-commands/mock-servers/index.ts b/shared/server-commands/mock-servers/index.ts new file mode 100644 index 000000000..93c00c788 --- /dev/null +++ b/shared/server-commands/mock-servers/index.ts @@ -0,0 +1,5 @@ +export * from './mock-email' +export * from './mock-instances-index' +export * from './mock-joinpeertube-versions' +export * from './mock-plugin-blocklist' +export * from './mock-object-storage' diff --git a/shared/server-commands/mock-servers/mock-429.ts b/shared/server-commands/mock-servers/mock-429.ts new file mode 100644 index 000000000..9e0d1281a --- /dev/null +++ b/shared/server-commands/mock-servers/mock-429.ts @@ -0,0 +1,33 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './utils' + +export class Mock429 { + private server: Server + private responseSent = false + + async initialize () { + const app = express() + + app.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => { + + if (!this.responseSent) { + this.responseSent = true + + // Retry after 5 seconds + res.header('retry-after', '2') + return res.sendStatus(429) + } + + return res.sendStatus(200) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/shared/server-commands/mock-servers/mock-email.ts b/shared/server-commands/mock-servers/mock-email.ts new file mode 100644 index 000000000..f646c1621 --- /dev/null +++ b/shared/server-commands/mock-servers/mock-email.ts @@ -0,0 +1,63 @@ +import { ChildProcess } from 'child_process' +import MailDev from '@peertube/maildev' +import { randomInt } from '@shared/core-utils' +import { parallelTests } from '../miscs' + +class MockSmtpServer { + + private static instance: MockSmtpServer + private started = false + private emailChildProcess: ChildProcess + private emails: object[] + + private constructor () { } + + collectEmails (emailsCollection: object[]) { + return new Promise((res, rej) => { + const port = parallelTests() ? randomInt(1000, 2000) : 1025 + this.emails = emailsCollection + + if (this.started) { + return res(undefined) + } + + const maildev = new MailDev({ + ip: '127.0.0.1', + smtp: port, + disableWeb: true, + silent: true + }) + + maildev.on('new', email => { + this.emails.push(email) + }) + + maildev.listen(err => { + if (err) return rej(err) + + this.started = true + + return res(port) + }) + }) + } + + kill () { + if (!this.emailChildProcess) return + + process.kill(this.emailChildProcess.pid) + + this.emailChildProcess = null + MockSmtpServer.instance = null + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + MockSmtpServer +} diff --git a/shared/server-commands/mock-servers/mock-instances-index.ts b/shared/server-commands/mock-servers/mock-instances-index.ts new file mode 100644 index 000000000..92b12d6f3 --- /dev/null +++ b/shared/server-commands/mock-servers/mock-instances-index.ts @@ -0,0 +1,46 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './utils' + +export class MockInstancesIndex { + private server: Server + + private readonly indexInstances: { host: string, createdAt: string }[] = [] + + async initialize () { + const app = express() + + app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url) + + return next() + }) + + app.get('/api/v1/instances/hosts', (req: express.Request, res: express.Response) => { + const since = req.query.since + + const filtered = this.indexInstances.filter(i => { + if (!since) return true + + return i.createdAt > since + }) + + return res.json({ + total: filtered.length, + data: filtered + }) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + addInstance (host: string) { + this.indexInstances.push({ host, createdAt: new Date().toISOString() }) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/shared/server-commands/mock-servers/mock-joinpeertube-versions.ts b/shared/server-commands/mock-servers/mock-joinpeertube-versions.ts new file mode 100644 index 000000000..e7906ea56 --- /dev/null +++ b/shared/server-commands/mock-servers/mock-joinpeertube-versions.ts @@ -0,0 +1,34 @@ +import express from 'express' +import { Server } from 'http' +import { getPort, randomListen } from './utils' + +export class MockJoinPeerTubeVersions { + private server: Server + private latestVersion: string + + async initialize () { + const app = express() + + app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url) + + return next() + }) + + app.get('/versions.json', (req: express.Request, res: express.Response) => { + return res.json({ + peertube: { + latestVersion: this.latestVersion + } + }) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + setLatestVersion (latestVersion: string) { + this.latestVersion = latestVersion + } +} diff --git a/shared/server-commands/mock-servers/mock-object-storage.ts b/shared/server-commands/mock-servers/mock-object-storage.ts new file mode 100644 index 000000000..d135c2631 --- /dev/null +++ b/shared/server-commands/mock-servers/mock-object-storage.ts @@ -0,0 +1,41 @@ +import express from 'express' +import got, { RequestError } from 'got' +import { Server } from 'http' +import { pipeline } from 'stream' +import { ObjectStorageCommand } from '../server' +import { getPort, randomListen, terminateServer } from './utils' + +export class MockObjectStorage { + private server: Server + + async initialize () { + const app = express() + + app.get('/:bucketName/:path(*)', (req: express.Request, res: express.Response, next: express.NextFunction) => { + const url = `http://${req.params.bucketName}.${ObjectStorageCommand.getEndpointHost()}/${req.params.path}` + + if (process.env.DEBUG) { + console.log('Receiving request on mocked server %s.', req.url) + console.log('Proxifying request to %s', url) + } + + return pipeline( + got.stream(url, { throwHttpErrors: false }), + res, + (err: RequestError) => { + if (!err) return + + console.error('Pipeline failed.', err) + } + ) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/shared/server-commands/mock-servers/mock-plugin-blocklist.ts b/shared/server-commands/mock-servers/mock-plugin-blocklist.ts new file mode 100644 index 000000000..f8a271cba --- /dev/null +++ b/shared/server-commands/mock-servers/mock-plugin-blocklist.ts @@ -0,0 +1,36 @@ +import express, { Request, Response } from 'express' +import { Server } from 'http' +import { getPort, randomListen, terminateServer } from './utils' + +type BlocklistResponse = { + data: { + value: string + action?: 'add' | 'remove' + updatedAt?: string + }[] +} + +export class MockBlocklist { + private body: BlocklistResponse + private server: Server + + async initialize () { + const app = express() + + app.get('/blocklist', (req: Request, res: Response) => { + return res.json(this.body) + }) + + this.server = await randomListen(app) + + return getPort(this.server) + } + + replace (body: BlocklistResponse) { + this.body = body + } + + terminate () { + return terminateServer(this.server) + } +} diff --git a/shared/server-commands/mock-servers/mock-proxy.ts b/shared/server-commands/mock-servers/mock-proxy.ts new file mode 100644 index 000000000..75ac79055 --- /dev/null +++ b/shared/server-commands/mock-servers/mock-proxy.ts @@ -0,0 +1,25 @@ + +import { createServer, Server } from 'http' +import proxy from 'proxy' +import { getPort, terminateServer } from './utils' + +class MockProxy { + private server: Server + + initialize () { + return new Promise(res => { + this.server = proxy(createServer()) + this.server.listen(0, () => res(getPort(this.server))) + }) + } + + terminate () { + return terminateServer(this.server) + } +} + +// --------------------------------------------------------------------------- + +export { + MockProxy +} diff --git a/shared/server-commands/mock-servers/utils.ts b/shared/server-commands/mock-servers/utils.ts new file mode 100644 index 000000000..235642439 --- /dev/null +++ b/shared/server-commands/mock-servers/utils.ts @@ -0,0 +1,33 @@ +import { Express } from 'express' +import { Server } from 'http' +import { AddressInfo } from 'net' + +function randomListen (app: Express) { + return new Promise(res => { + const server = app.listen(0, () => res(server)) + }) +} + +function getPort (server: Server) { + const address = server.address() as AddressInfo + + return address.port +} + +function terminateServer (server: Server) { + if (!server) return Promise.resolve() + + return new Promise((res, rej) => { + server.close(err => { + if (err) return rej(err) + + return res() + }) + }) +} + +export { + randomListen, + getPort, + terminateServer +} diff --git a/shared/server-commands/moderation/abuses-command.ts b/shared/server-commands/moderation/abuses-command.ts new file mode 100644 index 000000000..0db32ba46 --- /dev/null +++ b/shared/server-commands/moderation/abuses-command.ts @@ -0,0 +1,228 @@ +import { pick } from '@shared/core-utils' +import { + AbuseFilter, + AbuseMessage, + AbusePredefinedReasonsString, + AbuseState, + AbuseUpdate, + AbuseVideoIs, + AdminAbuse, + HttpStatusCode, + ResultList, + UserAbuse +} from '@shared/models' +import { unwrapBody } from '../requests/requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class AbusesCommand extends AbstractCommand { + + report (options: OverrideCommandOptions & { + reason: string + + accountId?: number + videoId?: number + commentId?: number + + predefinedReasons?: AbusePredefinedReasonsString[] + + startAt?: number + endAt?: number + }) { + const path = '/api/v1/abuses' + + const video = options.videoId + ? { + id: options.videoId, + startAt: options.startAt, + endAt: options.endAt + } + : undefined + + const comment = options.commentId + ? { id: options.commentId } + : undefined + + const account = options.accountId + ? { id: options.accountId } + : undefined + + const body = { + account, + video, + comment, + + reason: options.reason, + predefinedReasons: options.predefinedReasons + } + + return unwrapBody<{ abuse: { id: number } }>(this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getAdminList (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + id?: number + predefinedReason?: AbusePredefinedReasonsString + search?: string + filter?: AbuseFilter + state?: AbuseState + videoIs?: AbuseVideoIs + searchReporter?: string + searchReportee?: string + searchVideo?: string + searchVideoChannel?: string + } = {}) { + const toPick: (keyof typeof options)[] = [ + 'count', + 'filter', + 'id', + 'predefinedReason', + 'search', + 'searchReportee', + 'searchReporter', + 'searchVideo', + 'searchVideoChannel', + 'sort', + 'start', + 'state', + 'videoIs' + ] + + const path = '/api/v1/abuses' + + const defaultQuery = { sort: 'createdAt' } + const query = { ...defaultQuery, ...pick(options, toPick) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getUserList (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + + id?: number + search?: string + state?: AbuseState + }) { + const toPick: (keyof typeof options)[] = [ + 'id', + 'search', + 'state', + 'start', + 'count', + 'sort' + ] + + const path = '/api/v1/users/me/abuses' + + const defaultQuery = { sort: 'createdAt' } + const query = { ...defaultQuery, ...pick(options, toPick) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + abuseId: number + body: AbuseUpdate + }) { + const { abuseId, body } = options + const path = '/api/v1/abuses/' + abuseId + + return this.putBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + abuseId: number + }) { + const { abuseId } = options + const path = '/api/v1/abuses/' + abuseId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listMessages (options: OverrideCommandOptions & { + abuseId: number + }) { + const { abuseId } = options + const path = '/api/v1/abuses/' + abuseId + '/messages' + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMessage (options: OverrideCommandOptions & { + abuseId: number + messageId: number + }) { + const { abuseId, messageId } = options + const path = '/api/v1/abuses/' + abuseId + '/messages/' + messageId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addMessage (options: OverrideCommandOptions & { + abuseId: number + message: string + }) { + const { abuseId, message } = options + const path = '/api/v1/abuses/' + abuseId + '/messages' + + return this.postBodyRequest({ + ...options, + + path, + fields: { message }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/shared/server-commands/moderation/index.ts b/shared/server-commands/moderation/index.ts new file mode 100644 index 000000000..b37643956 --- /dev/null +++ b/shared/server-commands/moderation/index.ts @@ -0,0 +1 @@ +export * from './abuses-command' diff --git a/shared/server-commands/overviews/index.ts b/shared/server-commands/overviews/index.ts new file mode 100644 index 000000000..e19551907 --- /dev/null +++ b/shared/server-commands/overviews/index.ts @@ -0,0 +1 @@ +export * from './overviews-command' diff --git a/shared/server-commands/overviews/overviews-command.ts b/shared/server-commands/overviews/overviews-command.ts new file mode 100644 index 000000000..06b4892d2 --- /dev/null +++ b/shared/server-commands/overviews/overviews-command.ts @@ -0,0 +1,23 @@ +import { HttpStatusCode, VideosOverview } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class OverviewsCommand extends AbstractCommand { + + getVideos (options: OverrideCommandOptions & { + page: number + }) { + const { page } = options + const path = '/api/v1/overviews/videos' + + const query = { page } + + return this.getRequestBody({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/requests/check-api-params.ts b/shared/server-commands/requests/check-api-params.ts new file mode 100644 index 000000000..26ba1e913 --- /dev/null +++ b/shared/server-commands/requests/check-api-params.ts @@ -0,0 +1,48 @@ +import { HttpStatusCode } from '@shared/models' +import { makeGetRequest } from './requests' + +function checkBadStartPagination (url: string, path: string, token?: string, query = {}) { + return makeGetRequest({ + url, + path, + token, + query: { ...query, start: 'hello' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) +} + +async function checkBadCountPagination (url: string, path: string, token?: string, query = {}) { + await makeGetRequest({ + url, + path, + token, + query: { ...query, count: 'hello' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url, + path, + token, + query: { ...query, count: 2000 }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) +} + +function checkBadSortPagination (url: string, path: string, token?: string, query = {}) { + return makeGetRequest({ + url, + path, + token, + query: { ...query, sort: 'hello' }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) +} + +// --------------------------------------------------------------------------- + +export { + checkBadStartPagination, + checkBadCountPagination, + checkBadSortPagination +} diff --git a/shared/server-commands/requests/index.ts b/shared/server-commands/requests/index.ts new file mode 100644 index 000000000..501163f92 --- /dev/null +++ b/shared/server-commands/requests/index.ts @@ -0,0 +1,3 @@ +// Don't include activitypub that import stuff from server +export * from './check-api-params' +export * from './requests' diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts new file mode 100644 index 000000000..b6b9024ed --- /dev/null +++ b/shared/server-commands/requests/requests.ts @@ -0,0 +1,208 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ + +import { decode } from 'querystring' +import request from 'supertest' +import { URL } from 'url' +import { HttpStatusCode } from '@shared/models' +import { buildAbsoluteFixturePath } from '../miscs/tests' + +export type CommonRequestParams = { + url: string + path?: string + contentType?: string + range?: string + redirects?: number + accept?: string + host?: string + token?: string + headers?: { [ name: string ]: string } + type?: string + xForwardedFor?: string + expectedStatus?: HttpStatusCode +} + +function makeRawRequest (url: string, expectedStatus?: HttpStatusCode, range?: string) { + const { host, protocol, pathname } = new URL(url) + + return makeGetRequest({ url: `${protocol}//${host}`, path: pathname, expectedStatus, range }) +} + +function makeGetRequest (options: CommonRequestParams & { + query?: any + rawQuery?: string +}) { + const req = request(options.url).get(options.path) + + if (options.query) req.query(options.query) + if (options.rawQuery) req.query(options.rawQuery) + + return buildRequest(req, { contentType: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makeHTMLRequest (url: string, path: string) { + return makeGetRequest({ + url, + path, + accept: 'text/html', + expectedStatus: HttpStatusCode.OK_200 + }) +} + +function makeActivityPubGetRequest (url: string, path: string, expectedStatus = HttpStatusCode.OK_200) { + return makeGetRequest({ + url, + path, + expectedStatus: expectedStatus, + accept: 'application/activity+json,text/html;q=0.9,\\*/\\*;q=0.8' + }) +} + +function makeDeleteRequest (options: CommonRequestParams & { + query?: any + rawQuery?: string +}) { + const req = request(options.url).delete(options.path) + + if (options.query) req.query(options.query) + if (options.rawQuery) req.query(options.rawQuery) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makeUploadRequest (options: CommonRequestParams & { + method?: 'POST' | 'PUT' + + fields: { [ fieldName: string ]: any } + attaches?: { [ attachName: string ]: any | any[] } +}) { + let req = options.method === 'PUT' + ? request(options.url).put(options.path) + : request(options.url).post(options.path) + + req = buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) + + buildFields(req, options.fields) + + Object.keys(options.attaches || {}).forEach(attach => { + const value = options.attaches[attach] + if (!value) return + + if (Array.isArray(value)) { + req.attach(attach, buildAbsoluteFixturePath(value[0]), value[1]) + } else { + req.attach(attach, buildAbsoluteFixturePath(value)) + } + }) + + return req +} + +function makePostBodyRequest (options: CommonRequestParams & { + fields?: { [ fieldName: string ]: any } +}) { + const req = request(options.url).post(options.path) + .send(options.fields) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function makePutBodyRequest (options: { + url: string + path: string + token?: string + fields: { [ fieldName: string ]: any } + expectedStatus?: HttpStatusCode +}) { + const req = request(options.url).put(options.path) + .send(options.fields) + + return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options }) +} + +function decodeQueryString (path: string) { + return decode(path.split('?')[1]) +} + +function unwrapBody (test: request.Test): Promise { + return test.then(res => res.body) +} + +function unwrapText (test: request.Test): Promise { + return test.then(res => res.text) +} + +function unwrapBodyOrDecodeToJSON (test: request.Test): Promise { + return test.then(res => { + if (res.body instanceof Buffer) { + return JSON.parse(new TextDecoder().decode(res.body)) + } + + return res.body + }) +} + +function unwrapTextOrDecode (test: request.Test): Promise { + return test.then(res => res.text || new TextDecoder().decode(res.body)) +} + +// --------------------------------------------------------------------------- + +export { + makeHTMLRequest, + makeGetRequest, + decodeQueryString, + makeUploadRequest, + makePostBodyRequest, + makePutBodyRequest, + makeDeleteRequest, + makeRawRequest, + makeActivityPubGetRequest, + unwrapBody, + unwrapTextOrDecode, + unwrapBodyOrDecodeToJSON, + unwrapText +} + +// --------------------------------------------------------------------------- + +function buildRequest (req: request.Test, options: CommonRequestParams) { + if (options.contentType) req.set('Accept', options.contentType) + if (options.token) req.set('Authorization', 'Bearer ' + options.token) + if (options.range) req.set('Range', options.range) + if (options.accept) req.set('Accept', options.accept) + if (options.host) req.set('Host', options.host) + if (options.redirects) req.redirects(options.redirects) + if (options.expectedStatus) req.expect(options.expectedStatus) + if (options.xForwardedFor) req.set('X-Forwarded-For', options.xForwardedFor) + if (options.type) req.type(options.type) + + Object.keys(options.headers || {}).forEach(name => { + req.set(name, options.headers[name]) + }) + + return req +} + +function buildFields (req: request.Test, fields: { [ fieldName: string ]: any }, namespace?: string) { + if (!fields) return + + let formKey: string + + for (const key of Object.keys(fields)) { + if (namespace) formKey = `${namespace}[${key}]` + else formKey = key + + if (fields[key] === undefined) continue + + if (Array.isArray(fields[key]) && fields[key].length === 0) { + req.field(key, []) + continue + } + + if (fields[key] !== null && typeof fields[key] === 'object') { + buildFields(req, fields[key], formKey) + } else { + req.field(formKey, fields[key]) + } + } +} diff --git a/shared/server-commands/search/index.ts b/shared/server-commands/search/index.ts new file mode 100644 index 000000000..48dbe8ae9 --- /dev/null +++ b/shared/server-commands/search/index.ts @@ -0,0 +1 @@ +export * from './search-command' diff --git a/shared/server-commands/search/search-command.ts b/shared/server-commands/search/search-command.ts new file mode 100644 index 000000000..0fbbcd6ef --- /dev/null +++ b/shared/server-commands/search/search-command.ts @@ -0,0 +1,98 @@ +import { + HttpStatusCode, + ResultList, + Video, + VideoChannel, + VideoChannelsSearchQuery, + VideoPlaylist, + VideoPlaylistsSearchQuery, + VideosSearchQuery +} from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class SearchCommand extends AbstractCommand { + + searchChannels (options: OverrideCommandOptions & { + search: string + }) { + return this.advancedChannelSearch({ + ...options, + + search: { search: options.search } + }) + } + + advancedChannelSearch (options: OverrideCommandOptions & { + search: VideoChannelsSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + searchPlaylists (options: OverrideCommandOptions & { + search: string + }) { + return this.advancedPlaylistSearch({ + ...options, + + search: { search: options.search } + }) + } + + advancedPlaylistSearch (options: OverrideCommandOptions & { + search: VideoPlaylistsSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/video-playlists' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + searchVideos (options: OverrideCommandOptions & { + search: string + sort?: string + }) { + const { search, sort } = options + + return this.advancedVideoSearch({ + ...options, + + search: { + search: search, + sort: sort ?? '-publishedAt' + } + }) + } + + advancedVideoSearch (options: OverrideCommandOptions & { + search: VideosSearchQuery + }) { + const { search } = options + const path = '/api/v1/search/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: search, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts new file mode 100644 index 000000000..89ae8eb4f --- /dev/null +++ b/shared/server-commands/server/config-command.ts @@ -0,0 +1,353 @@ +import { merge } from 'lodash' +import { DeepPartial } from '@shared/typescript-utils' +import { About, HttpStatusCode, ServerConfig } from '@shared/models' +import { CustomConfig } from '../../models/server/custom-config.model' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ConfigCommand extends AbstractCommand { + + static getCustomConfigResolutions (enabled: boolean) { + return { + '144p': enabled, + '240p': enabled, + '360p': enabled, + '480p': enabled, + '720p': enabled, + '1080p': enabled, + '1440p': enabled, + '2160p': enabled + } + } + + enableImports () { + return this.updateExistingSubConfig({ + newConfig: { + import: { + videos: { + http: { + enabled: true + }, + + torrent: { + enabled: true + } + } + } + } + }) + } + + enableLive (options: { + allowReplay?: boolean + transcoding?: boolean + } = {}) { + return this.updateExistingSubConfig({ + newConfig: { + live: { + enabled: true, + allowReplay: options.allowReplay ?? true, + transcoding: { + enabled: options.transcoding ?? true, + resolutions: ConfigCommand.getCustomConfigResolutions(true) + } + } + } + }) + } + + disableTranscoding () { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: false + } + } + }) + } + + enableTranscoding (webtorrent = true, hls = true) { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + resolutions: ConfigCommand.getCustomConfigResolutions(true), + + webtorrent: { + enabled: webtorrent + }, + hls: { + enabled: hls + } + } + } + }) + } + + getConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getAbout (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/about' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getCustomConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/custom' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateCustomConfig (options: OverrideCommandOptions & { + newCustomConfig: CustomConfig + }) { + const path = '/api/v1/config/custom' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.newCustomConfig, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteCustomConfig (options: OverrideCommandOptions = {}) { + const path = '/api/v1/config/custom' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async updateExistingSubConfig (options: OverrideCommandOptions & { + newConfig: DeepPartial + }) { + const existing = await this.getCustomConfig(options) + + return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) }) + } + + updateCustomSubConfig (options: OverrideCommandOptions & { + newConfig: DeepPartial + }) { + const newCustomConfig: CustomConfig = { + instance: { + name: 'PeerTube updated', + shortDescription: 'my short description', + description: 'my super description', + terms: 'my super terms', + codeOfConduct: 'my super coc', + + creationReason: 'my super creation reason', + moderationInformation: 'my super moderation information', + administrator: 'Kuja', + maintenanceLifetime: 'forever', + businessModel: 'my super business model', + hardwareInformation: '2vCore 3GB RAM', + + languages: [ 'en', 'es' ], + categories: [ 1, 2 ], + + isNSFW: true, + defaultNSFWPolicy: 'blur', + + defaultClientRoute: '/videos/recently-added', + + customizations: { + javascript: 'alert("coucou")', + css: 'body { background-color: red; }' + } + }, + theme: { + default: 'default' + }, + services: { + twitter: { + username: '@MySuperUsername', + whitelisted: true + } + }, + client: { + videos: { + miniature: { + preferAuthorDisplayName: false + } + }, + menu: { + login: { + redirectOnSingleExternalAuth: false + } + } + }, + cache: { + previews: { + size: 2 + }, + captions: { + size: 3 + }, + torrents: { + size: 4 + } + }, + signup: { + enabled: false, + limit: 5, + requiresEmailVerification: false, + minimumAge: 16 + }, + admin: { + email: 'superadmin1@example.com' + }, + contactForm: { + enabled: true + }, + user: { + videoQuota: 5242881, + videoQuotaDaily: 318742 + }, + videoChannels: { + maxPerUser: 20 + }, + transcoding: { + enabled: true, + allowAdditionalExtensions: true, + allowAudioFiles: true, + threads: 1, + concurrency: 3, + profile: 'default', + resolutions: { + '0p': false, + '144p': false, + '240p': false, + '360p': true, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + webtorrent: { + enabled: true + }, + hls: { + enabled: false + } + }, + live: { + enabled: true, + allowReplay: false, + maxDuration: -1, + maxInstanceLives: -1, + maxUserLives: 50, + transcoding: { + enabled: true, + threads: 4, + profile: 'default', + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + } + } + }, + import: { + videos: { + concurrency: 3, + http: { + enabled: false + }, + torrent: { + enabled: false + } + } + }, + trending: { + videos: { + algorithms: { + enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], + default: 'hot' + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: false + } + } + }, + followers: { + instance: { + enabled: true, + manualApproval: false + } + }, + followings: { + instance: { + autoFollowBack: { + enabled: false + }, + autoFollowIndex: { + indexUrl: 'https://instances.joinpeertube.org/api/v1/instances/hosts', + enabled: false + } + } + }, + broadcastMessage: { + enabled: true, + level: 'warning', + message: 'hello', + dismissable: true + }, + search: { + remoteUri: { + users: true, + anonymous: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } + } + } + + merge(newCustomConfig, options.newConfig) + + return this.updateCustomConfig({ ...options, newCustomConfig }) + } +} diff --git a/shared/server-commands/server/contact-form-command.ts b/shared/server-commands/server/contact-form-command.ts new file mode 100644 index 000000000..0e8fd6d84 --- /dev/null +++ b/shared/server-commands/server/contact-form-command.ts @@ -0,0 +1,31 @@ +import { HttpStatusCode } from '@shared/models' +import { ContactForm } from '../../models/server' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ContactFormCommand extends AbstractCommand { + + send (options: OverrideCommandOptions & { + fromEmail: string + fromName: string + subject: string + body: string + }) { + const path = '/api/v1/server/contact' + + const body: ContactForm = { + fromEmail: options.fromEmail, + fromName: options.fromName, + subject: options.subject, + body: options.body + } + + return this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/server/debug-command.ts b/shared/server-commands/server/debug-command.ts new file mode 100644 index 000000000..3c5a785bb --- /dev/null +++ b/shared/server-commands/server/debug-command.ts @@ -0,0 +1,33 @@ +import { Debug, HttpStatusCode, SendDebugCommand } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class DebugCommand extends AbstractCommand { + + getDebug (options: OverrideCommandOptions = {}) { + const path = '/api/v1/server/debug' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + sendCommand (options: OverrideCommandOptions & { + body: SendDebugCommand + }) { + const { body } = options + const path = '/api/v1/server/debug/run-command' + + return this.postBodyRequest({ + ...options, + + path, + fields: body, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/server/directories.ts b/shared/server-commands/server/directories.ts new file mode 100644 index 000000000..e6f72d6fc --- /dev/null +++ b/shared/server-commands/server/directories.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists, readdir } from 'fs-extra' +import { join } from 'path' +import { root } from '@shared/core-utils' +import { PeerTubeServer } from './server' + +async function checkTmpIsEmpty (server: PeerTubeServer) { + await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls', 'resumable-uploads' ]) + + if (await pathExists(join('test' + server.internalServerNumber, 'tmp', 'hls'))) { + await checkDirectoryIsEmpty(server, 'tmp/hls') + } +} + +async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) { + const testDirectory = 'test' + server.internalServerNumber + + const directoryPath = join(root(), testDirectory, directory) + + const directoryExists = await pathExists(directoryPath) + expect(directoryExists).to.be.true + + const files = await readdir(directoryPath) + const filtered = files.filter(f => exceptions.includes(f) === false) + + expect(filtered).to.have.lengthOf(0) +} + +export { + checkTmpIsEmpty, + checkDirectoryIsEmpty +} diff --git a/shared/server-commands/server/follows-command.ts b/shared/server-commands/server/follows-command.ts new file mode 100644 index 000000000..01ef6f179 --- /dev/null +++ b/shared/server-commands/server/follows-command.ts @@ -0,0 +1,139 @@ +import { pick } from '@shared/core-utils' +import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' +import { PeerTubeServer } from './server' + +export class FollowsCommand extends AbstractCommand { + + getFollowers (options: OverrideCommandOptions & { + start: number + count: number + sort: string + search?: string + actorType?: ActivityPubActorType + state?: FollowState + }) { + const path = '/api/v1/server/followers' + + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getFollowings (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + actorType?: ActivityPubActorType + state?: FollowState + } = {}) { + const path = '/api/v1/server/following' + + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + follow (options: OverrideCommandOptions & { + hosts?: string[] + handles?: string[] + }) { + const path = '/api/v1/server/following' + + const fields: ServerFollowCreate = {} + + if (options.hosts) { + fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, '')) + } + + if (options.handles) { + fields.handles = options.handles + } + + return this.postBodyRequest({ + ...options, + + path, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async unfollow (options: OverrideCommandOptions & { + target: PeerTubeServer | string + }) { + const { target } = options + + const handle = typeof target === 'string' + ? target + : target.host + + const path = '/api/v1/server/following/' + handle + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + acceptFollower (options: OverrideCommandOptions & { + follower: string + }) { + const path = '/api/v1/server/followers/' + options.follower + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + rejectFollower (options: OverrideCommandOptions & { + follower: string + }) { + const path = '/api/v1/server/followers/' + options.follower + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFollower (options: OverrideCommandOptions & { + follower: PeerTubeServer + }) { + const path = '/api/v1/server/followers/peertube@' + options.follower.host + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/server/follows.ts b/shared/server-commands/server/follows.ts new file mode 100644 index 000000000..698238f29 --- /dev/null +++ b/shared/server-commands/server/follows.ts @@ -0,0 +1,20 @@ +import { waitJobs } from './jobs' +import { PeerTubeServer } from './server' + +async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { + await Promise.all([ + server1.follows.follow({ hosts: [ server2.url ] }), + server2.follows.follow({ hosts: [ server1.url ] }) + ]) + + // Wait request propagation + await waitJobs([ server1, server2 ]) + + return true +} + +// --------------------------------------------------------------------------- + +export { + doubleFollow +} diff --git a/shared/server-commands/server/index.ts b/shared/server-commands/server/index.ts new file mode 100644 index 000000000..76a2099da --- /dev/null +++ b/shared/server-commands/server/index.ts @@ -0,0 +1,17 @@ +export * from './config-command' +export * from './contact-form-command' +export * from './debug-command' +export * from './directories' +export * from './follows-command' +export * from './follows' +export * from './jobs' +export * from './jobs-command' +export * from './object-storage-command' +export * from './plugins-command' +export * from './plugins' +export * from './redundancy-command' +export * from './server' +export * from './servers-command' +export * from './servers' +export * from './stats-command' +export * from './tracker' diff --git a/shared/server-commands/server/jobs-command.ts b/shared/server-commands/server/jobs-command.ts new file mode 100644 index 000000000..6636e7e4d --- /dev/null +++ b/shared/server-commands/server/jobs-command.ts @@ -0,0 +1,61 @@ +import { pick } from '@shared/core-utils' +import { HttpStatusCode } from '@shared/models' +import { Job, JobState, JobType, ResultList } from '../../models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class JobsCommand extends AbstractCommand { + + async getLatest (options: OverrideCommandOptions & { + jobType: JobType + }) { + const { data } = await this.list({ ...options, start: 0, count: 1, sort: '-createdAt' }) + + if (data.length === 0) return undefined + + return data[0] + } + + list (options: OverrideCommandOptions & { + state?: JobState + jobType?: JobType + start?: number + count?: number + sort?: string + } = {}) { + const path = this.buildJobsUrl(options.state) + + const query = pick(options, [ 'start', 'count', 'sort', 'jobType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFailed (options: OverrideCommandOptions & { + jobType?: JobType + }) { + const path = this.buildJobsUrl('failed') + + return this.getRequestBody>({ + ...options, + + path, + query: { start: 0, count: 50 }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private buildJobsUrl (state?: JobState) { + let path = '/api/v1/jobs' + + if (state) path += '/' + state + + return path + } +} diff --git a/shared/server-commands/server/jobs.ts b/shared/server-commands/server/jobs.ts new file mode 100644 index 000000000..34fefd444 --- /dev/null +++ b/shared/server-commands/server/jobs.ts @@ -0,0 +1,84 @@ + +import { expect } from 'chai' +import { JobState, JobType } from '../../models' +import { wait } from '../miscs' +import { PeerTubeServer } from './server' + +async function waitJobs (serversArg: PeerTubeServer[] | PeerTubeServer, skipDelayed = false) { + const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT + ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10) + : 250 + + let servers: PeerTubeServer[] + + if (Array.isArray(serversArg) === false) servers = [ serversArg as PeerTubeServer ] + else servers = serversArg as PeerTubeServer[] + + const states: JobState[] = [ 'waiting', 'active' ] + if (!skipDelayed) states.push('delayed') + + const repeatableJobs: JobType[] = [ 'videos-views-stats', 'activitypub-cleaner' ] + let pendingRequests: boolean + + function tasksBuilder () { + const tasks: Promise[] = [] + + // Check if each server has pending request + for (const server of servers) { + for (const state of states) { + const p = server.jobs.list({ + state, + start: 0, + count: 10, + sort: '-createdAt' + }).then(body => body.data) + .then(jobs => jobs.filter(j => !repeatableJobs.includes(j.type))) + .then(jobs => { + if (jobs.length !== 0) { + pendingRequests = true + } + }) + + tasks.push(p) + } + + const p = server.debug.getDebug() + .then(obj => { + if (obj.activityPubMessagesWaiting !== 0) { + pendingRequests = true + } + }) + + tasks.push(p) + } + + return tasks + } + + do { + pendingRequests = false + await Promise.all(tasksBuilder()) + + // Retry, in case of new jobs were created + if (pendingRequests === false) { + await wait(pendingJobWait) + await Promise.all(tasksBuilder()) + } + + if (pendingRequests) { + await wait(pendingJobWait) + } + } while (pendingRequests) +} + +async function expectNoFailedTranscodingJob (server: PeerTubeServer) { + const { data } = await server.jobs.listFailed({ jobType: 'video-transcoding' }) + expect(data).to.have.lengthOf(0) +} + +// --------------------------------------------------------------------------- + +export { + waitJobs, + expectNoFailedTranscodingJob +} diff --git a/shared/server-commands/server/object-storage-command.ts b/shared/server-commands/server/object-storage-command.ts new file mode 100644 index 000000000..b4de8f4cb --- /dev/null +++ b/shared/server-commands/server/object-storage-command.ts @@ -0,0 +1,77 @@ + +import { HttpStatusCode } from '@shared/models' +import { makePostBodyRequest } from '../requests' +import { AbstractCommand } from '../shared' + +export class ObjectStorageCommand extends AbstractCommand { + static readonly DEFAULT_PLAYLIST_BUCKET = 'streaming-playlists' + static readonly DEFAULT_WEBTORRENT_BUCKET = 'videos' + + static getDefaultConfig () { + return { + object_storage: { + enabled: true, + endpoint: 'http://' + this.getEndpointHost(), + region: this.getRegion(), + + credentials: this.getCredentialsConfig(), + + streaming_playlists: { + bucket_name: this.DEFAULT_PLAYLIST_BUCKET + }, + + videos: { + bucket_name: this.DEFAULT_WEBTORRENT_BUCKET + } + } + } + } + + static getCredentialsConfig () { + return { + access_key_id: 'AKIAIOSFODNN7EXAMPLE', + secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + } + } + + static getEndpointHost () { + return 'localhost:9444' + } + + static getRegion () { + return 'us-east-1' + } + + static getWebTorrentBaseUrl () { + return `http://${this.DEFAULT_WEBTORRENT_BUCKET}.${this.getEndpointHost()}/` + } + + static getPlaylistBaseUrl () { + return `http://${this.DEFAULT_PLAYLIST_BUCKET}.${this.getEndpointHost()}/` + } + + static async prepareDefaultBuckets () { + await this.createBucket(this.DEFAULT_PLAYLIST_BUCKET) + await this.createBucket(this.DEFAULT_WEBTORRENT_BUCKET) + } + + static async createBucket (name: string) { + await makePostBodyRequest({ + url: this.getEndpointHost(), + path: '/ui/' + name + '?delete', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + + await makePostBodyRequest({ + url: this.getEndpointHost(), + path: '/ui/' + name + '?create', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + + await makePostBodyRequest({ + url: this.getEndpointHost(), + path: '/ui/' + name + '?make-public', + expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307 + }) + } +} diff --git a/shared/server-commands/server/plugins-command.ts b/shared/server-commands/server/plugins-command.ts new file mode 100644 index 000000000..1c44711da --- /dev/null +++ b/shared/server-commands/server/plugins-command.ts @@ -0,0 +1,257 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { readJSON, writeJSON } from 'fs-extra' +import { join } from 'path' +import { root } from '@shared/core-utils' +import { + HttpStatusCode, + PeerTubePlugin, + PeerTubePluginIndex, + PeertubePluginIndexList, + PluginPackageJson, + PluginTranslation, + PluginType, + PublicServerSetting, + RegisteredServerSettings, + ResultList +} from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class PluginsCommand extends AbstractCommand { + + static getPluginTestPath (suffix = '') { + return join(root(), 'server', 'tests', 'fixtures', 'peertube-plugin-test' + suffix) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + pluginType?: PluginType + uninstalled?: boolean + }) { + const { start, count, sort, pluginType, uninstalled } = options + const path = '/api/v1/plugins' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + pluginType, + uninstalled + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listAvailable (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + pluginType?: PluginType + currentPeerTubeEngine?: string + search?: string + expectedStatus?: HttpStatusCode + }) { + const { start, count, sort, pluginType, search, currentPeerTubeEngine } = options + const path = '/api/v1/plugins/available' + + const query: PeertubePluginIndexList = { + start, + count, + sort, + pluginType, + currentPeerTubeEngine, + search + } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + npmName: string + }) { + const path = '/api/v1/plugins/' + options.npmName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateSettings (options: OverrideCommandOptions & { + npmName: string + settings: any + }) { + const { npmName, settings } = options + const path = '/api/v1/plugins/' + npmName + '/settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: { settings }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getRegisteredSettings (options: OverrideCommandOptions & { + npmName: string + }) { + const path = '/api/v1/plugins/' + options.npmName + '/registered-settings' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPublicSettings (options: OverrideCommandOptions & { + npmName: string + }) { + const { npmName } = options + const path = '/api/v1/plugins/' + npmName + '/public-settings' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getTranslations (options: OverrideCommandOptions & { + locale: string + }) { + const { locale } = options + const path = '/plugins/translations/' + locale + '.json' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + install (options: OverrideCommandOptions & { + path?: string + npmName?: string + pluginVersion?: string + }) { + const { npmName, path, pluginVersion } = options + const apiPath = '/api/v1/plugins/install' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName, path, pluginVersion }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + path?: string + npmName?: string + }) { + const { npmName, path } = options + const apiPath = '/api/v1/plugins/update' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName, path }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + uninstall (options: OverrideCommandOptions & { + npmName: string + }) { + const { npmName } = options + const apiPath = '/api/v1/plugins/uninstall' + + return this.postBodyRequest({ + ...options, + + path: apiPath, + fields: { npmName }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getCSS (options: OverrideCommandOptions = {}) { + const path = '/plugins/global.css' + + return this.getRequestText({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getExternalAuth (options: OverrideCommandOptions & { + npmName: string + npmVersion: string + authName: string + query?: any + }) { + const { npmName, npmVersion, authName, query } = options + + const path = '/plugins/' + npmName + '/' + npmVersion + '/auth/' + authName + + return this.getRequest({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200, + redirects: 0 + }) + } + + updatePackageJSON (npmName: string, json: any) { + const path = this.getPackageJSONPath(npmName) + + return writeJSON(path, json) + } + + getPackageJSON (npmName: string): Promise { + const path = this.getPackageJSONPath(npmName) + + return readJSON(path) + } + + private getPackageJSONPath (npmName: string) { + return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json')) + } +} diff --git a/shared/server-commands/server/plugins.ts b/shared/server-commands/server/plugins.ts new file mode 100644 index 000000000..c6316898d --- /dev/null +++ b/shared/server-commands/server/plugins.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { PeerTubeServer } from './server' + +async function testHelloWorldRegisteredSettings (server: PeerTubeServer) { + const body = await server.plugins.getRegisteredSettings({ npmName: 'peertube-plugin-hello-world' }) + + const registeredSettings = body.registeredSettings + expect(registeredSettings).to.have.length.at.least(1) + + const adminNameSettings = registeredSettings.find(s => s.name === 'admin-name') + expect(adminNameSettings).to.not.be.undefined +} + +export { + testHelloWorldRegisteredSettings +} diff --git a/shared/server-commands/server/redundancy-command.ts b/shared/server-commands/server/redundancy-command.ts new file mode 100644 index 000000000..e7a8b3c29 --- /dev/null +++ b/shared/server-commands/server/redundancy-command.ts @@ -0,0 +1,80 @@ +import { HttpStatusCode, ResultList, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class RedundancyCommand extends AbstractCommand { + + updateRedundancy (options: OverrideCommandOptions & { + host: string + redundancyAllowed: boolean + }) { + const { host, redundancyAllowed } = options + const path = '/api/v1/server/redundancy/' + host + + return this.putBodyRequest({ + ...options, + + path, + fields: { redundancyAllowed }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listVideos (options: OverrideCommandOptions & { + target: VideoRedundanciesTarget + start?: number + count?: number + sort?: string + }) { + const path = '/api/v1/server/redundancy/videos' + + const { target, start, count, sort } = options + + return this.getRequestBody>({ + ...options, + + path, + + query: { + start: start ?? 0, + count: count ?? 5, + sort: sort ?? 'name', + target + }, + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + addVideo (options: OverrideCommandOptions & { + videoId: number + }) { + const path = '/api/v1/server/redundancy/videos' + const { videoId } = options + + return this.postBodyRequest({ + ...options, + + path, + fields: { videoId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeVideo (options: OverrideCommandOptions & { + redundancyId: number + }) { + const { redundancyId } = options + const path = '/api/v1/server/redundancy/videos/' + redundancyId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts new file mode 100644 index 000000000..339b9cabb --- /dev/null +++ b/shared/server-commands/server/server.ts @@ -0,0 +1,392 @@ +import { ChildProcess, fork } from 'child_process' +import { copy } from 'fs-extra' +import { join } from 'path' +import { root, randomInt } from '@shared/core-utils' +import { Video, VideoChannel, VideoCreateResult, VideoDetails } from '../../models/videos' +import { BulkCommand } from '../bulk' +import { CLICommand } from '../cli' +import { CustomPagesCommand } from '../custom-pages' +import { FeedCommand } from '../feeds' +import { LogsCommand } from '../logs' +import { parallelTests, SQLCommand } from '../miscs' +import { AbusesCommand } from '../moderation' +import { OverviewsCommand } from '../overviews' +import { SearchCommand } from '../search' +import { SocketIOCommand } from '../socket' +import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users' +import { + BlacklistCommand, + CaptionsCommand, + ChangeOwnershipCommand, + ChannelsCommand, + HistoryCommand, + ImportsCommand, + LiveCommand, + PlaylistsCommand, + ServicesCommand, + StreamingPlaylistsCommand, + VideosCommand +} from '../videos' +import { CommentsCommand } from '../videos/comments-command' +import { ConfigCommand } from './config-command' +import { ContactFormCommand } from './contact-form-command' +import { DebugCommand } from './debug-command' +import { FollowsCommand } from './follows-command' +import { JobsCommand } from './jobs-command' +import { PluginsCommand } from './plugins-command' +import { RedundancyCommand } from './redundancy-command' +import { ServersCommand } from './servers-command' +import { StatsCommand } from './stats-command' +import { ObjectStorageCommand } from './object-storage-command' + +export type RunServerOptions = { + hideLogs?: boolean + nodeArgs?: string[] + peertubeArgs?: string[] + env?: { [ id: string ]: string } +} + +export class PeerTubeServer { + app?: ChildProcess + + url: string + host?: string + hostname?: string + port?: number + + rtmpPort?: number + rtmpsPort?: number + + parallel?: boolean + internalServerNumber: number + + serverNumber?: number + customConfigFile?: string + + store?: { + client?: { + id?: string + secret?: string + } + + user?: { + username: string + password: string + email?: string + } + + channel?: VideoChannel + + video?: Video + videoCreated?: VideoCreateResult + videoDetails?: VideoDetails + + videos?: { id: number, uuid: string }[] + } + + accessToken?: string + refreshToken?: string + + bulk?: BulkCommand + cli?: CLICommand + customPage?: CustomPagesCommand + feed?: FeedCommand + logs?: LogsCommand + abuses?: AbusesCommand + overviews?: OverviewsCommand + search?: SearchCommand + contactForm?: ContactFormCommand + debug?: DebugCommand + follows?: FollowsCommand + jobs?: JobsCommand + plugins?: PluginsCommand + redundancy?: RedundancyCommand + stats?: StatsCommand + config?: ConfigCommand + socketIO?: SocketIOCommand + accounts?: AccountsCommand + blocklist?: BlocklistCommand + subscriptions?: SubscriptionsCommand + live?: LiveCommand + services?: ServicesCommand + blacklist?: BlacklistCommand + captions?: CaptionsCommand + changeOwnership?: ChangeOwnershipCommand + playlists?: PlaylistsCommand + history?: HistoryCommand + imports?: ImportsCommand + streamingPlaylists?: StreamingPlaylistsCommand + channels?: ChannelsCommand + comments?: CommentsCommand + sql?: SQLCommand + notifications?: NotificationsCommand + servers?: ServersCommand + login?: LoginCommand + users?: UsersCommand + objectStorage?: ObjectStorageCommand + videos?: VideosCommand + + constructor (options: { serverNumber: number } | { url: string }) { + if ((options as any).url) { + this.setUrl((options as any).url) + } else { + this.setServerNumber((options as any).serverNumber) + } + + this.store = { + client: { + id: null, + secret: null + }, + user: { + username: null, + password: null + } + } + + this.assignCommands() + } + + setServerNumber (serverNumber: number) { + this.serverNumber = serverNumber + + this.parallel = parallelTests() + + this.internalServerNumber = this.parallel ? this.randomServer() : this.serverNumber + this.rtmpPort = this.parallel ? this.randomRTMP() : 1936 + this.rtmpsPort = this.parallel ? this.randomRTMP() : 1937 + this.port = 9000 + this.internalServerNumber + + this.url = `http://localhost:${this.port}` + this.host = `localhost:${this.port}` + this.hostname = 'localhost' + } + + setUrl (url: string) { + const parsed = new URL(url) + + this.url = url + this.host = parsed.host + this.hostname = parsed.hostname + this.port = parseInt(parsed.port) + } + + async flushAndRun (configOverride?: Object, options: RunServerOptions = {}) { + await ServersCommand.flushTests(this.internalServerNumber) + + return this.run(configOverride, options) + } + + async run (configOverrideArg?: any, options: RunServerOptions = {}) { + // These actions are async so we need to be sure that they have both been done + const serverRunString = { + 'HTTP server listening': false + } + const key = 'Database peertube_test' + this.internalServerNumber + ' is ready' + serverRunString[key] = false + + const regexps = { + client_id: 'Client id: (.+)', + client_secret: 'Client secret: (.+)', + user_username: 'Username: (.+)', + user_password: 'User password: (.+)' + } + + await this.assignCustomConfigFile() + + const configOverride = this.buildConfigOverride() + + if (configOverrideArg !== undefined) { + Object.assign(configOverride, configOverrideArg) + } + + // Share the environment + const env = Object.create(process.env) + env['NODE_ENV'] = 'test' + env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString() + env['NODE_CONFIG'] = JSON.stringify(configOverride) + + if (options.env) { + Object.assign(env, options.env) + } + + const forkOptions = { + silent: true, + env, + detached: true, + execArgv: options.nodeArgs || [] + } + + return new Promise((res, rej) => { + const self = this + let aggregatedLogs = '' + + this.app = fork(join(root(), 'dist', 'server.js'), options.peertubeArgs || [], forkOptions) + + const onPeerTubeExit = () => rej(new Error('Process exited:\n' + aggregatedLogs)) + const onParentExit = () => { + if (!this.app || !this.app.pid) return + + try { + process.kill(self.app.pid) + } catch { /* empty */ } + } + + this.app.on('exit', onPeerTubeExit) + process.on('exit', onParentExit) + + this.app.stdout.on('data', function onStdout (data) { + let dontContinue = false + + const log: string = data.toString() + aggregatedLogs += log + + // Capture things if we want to + for (const key of Object.keys(regexps)) { + const regexp = regexps[key] + const matches = log.match(regexp) + if (matches !== null) { + if (key === 'client_id') self.store.client.id = matches[1] + else if (key === 'client_secret') self.store.client.secret = matches[1] + else if (key === 'user_username') self.store.user.username = matches[1] + else if (key === 'user_password') self.store.user.password = matches[1] + } + } + + // Check if all required sentences are here + for (const key of Object.keys(serverRunString)) { + if (log.includes(key)) serverRunString[key] = true + if (serverRunString[key] === false) dontContinue = true + } + + // If no, there is maybe one thing not already initialized (client/user credentials generation...) + if (dontContinue === true) return + + if (options.hideLogs === false) { + console.log(log) + } else { + process.removeListener('exit', onParentExit) + self.app.stdout.removeListener('data', onStdout) + self.app.removeListener('exit', onPeerTubeExit) + } + + res() + }) + }) + } + + async kill () { + if (!this.app) return + + await this.sql.cleanup() + + process.kill(-this.app.pid) + + this.app = null + } + + private randomServer () { + const low = 10 + const high = 10000 + + return randomInt(low, high) + } + + private randomRTMP () { + const low = 1900 + const high = 2100 + + return randomInt(low, high) + } + + private async assignCustomConfigFile () { + if (this.internalServerNumber === this.serverNumber) return + + const basePath = join(root(), 'config') + + const tmpConfigFile = join(basePath, `test-${this.internalServerNumber}.yaml`) + await copy(join(basePath, `test-${this.serverNumber}.yaml`), tmpConfigFile) + + this.customConfigFile = tmpConfigFile + } + + private buildConfigOverride () { + if (!this.parallel) return {} + + return { + listen: { + port: this.port + }, + webserver: { + port: this.port + }, + database: { + suffix: '_test' + this.internalServerNumber + }, + storage: { + tmp: `test${this.internalServerNumber}/tmp/`, + bin: `test${this.internalServerNumber}/bin/`, + avatars: `test${this.internalServerNumber}/avatars/`, + videos: `test${this.internalServerNumber}/videos/`, + streaming_playlists: `test${this.internalServerNumber}/streaming-playlists/`, + redundancy: `test${this.internalServerNumber}/redundancy/`, + logs: `test${this.internalServerNumber}/logs/`, + previews: `test${this.internalServerNumber}/previews/`, + thumbnails: `test${this.internalServerNumber}/thumbnails/`, + torrents: `test${this.internalServerNumber}/torrents/`, + captions: `test${this.internalServerNumber}/captions/`, + cache: `test${this.internalServerNumber}/cache/`, + plugins: `test${this.internalServerNumber}/plugins/` + }, + admin: { + email: `admin${this.internalServerNumber}@example.com` + }, + live: { + rtmp: { + port: this.rtmpPort + } + } + } + } + + private assignCommands () { + this.bulk = new BulkCommand(this) + this.cli = new CLICommand(this) + this.customPage = new CustomPagesCommand(this) + this.feed = new FeedCommand(this) + this.logs = new LogsCommand(this) + this.abuses = new AbusesCommand(this) + this.overviews = new OverviewsCommand(this) + this.search = new SearchCommand(this) + this.contactForm = new ContactFormCommand(this) + this.debug = new DebugCommand(this) + this.follows = new FollowsCommand(this) + this.jobs = new JobsCommand(this) + this.plugins = new PluginsCommand(this) + this.redundancy = new RedundancyCommand(this) + this.stats = new StatsCommand(this) + this.config = new ConfigCommand(this) + this.socketIO = new SocketIOCommand(this) + this.accounts = new AccountsCommand(this) + this.blocklist = new BlocklistCommand(this) + this.subscriptions = new SubscriptionsCommand(this) + this.live = new LiveCommand(this) + this.services = new ServicesCommand(this) + this.blacklist = new BlacklistCommand(this) + this.captions = new CaptionsCommand(this) + this.changeOwnership = new ChangeOwnershipCommand(this) + this.playlists = new PlaylistsCommand(this) + this.history = new HistoryCommand(this) + this.imports = new ImportsCommand(this) + this.streamingPlaylists = new StreamingPlaylistsCommand(this) + this.channels = new ChannelsCommand(this) + this.comments = new CommentsCommand(this) + this.sql = new SQLCommand(this) + this.notifications = new NotificationsCommand(this) + this.servers = new ServersCommand(this) + this.login = new LoginCommand(this) + this.users = new UsersCommand(this) + this.videos = new VideosCommand(this) + this.objectStorage = new ObjectStorageCommand(this) + } +} diff --git a/shared/server-commands/server/servers-command.ts b/shared/server-commands/server/servers-command.ts new file mode 100644 index 000000000..47420c95f --- /dev/null +++ b/shared/server-commands/server/servers-command.ts @@ -0,0 +1,92 @@ +import { exec } from 'child_process' +import { copy, ensureDir, readFile, remove } from 'fs-extra' +import { basename, join } from 'path' +import { root } from '@shared/core-utils' +import { HttpStatusCode } from '@shared/models' +import { getFileSize, isGithubCI, wait } from '../miscs' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ServersCommand extends AbstractCommand { + + static flushTests (internalServerNumber: number) { + return new Promise((res, rej) => { + const suffix = ` -- ${internalServerNumber}` + + return exec('npm run clean:server:test' + suffix, (err, _stdout, stderr) => { + if (err || stderr) return rej(err || new Error(stderr)) + + return res() + }) + }) + } + + ping (options: OverrideCommandOptions = {}) { + return this.getRequestBody({ + ...options, + + path: '/api/v1/ping', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async cleanupTests () { + const p: Promise[] = [] + + if (isGithubCI()) { + await ensureDir('artifacts') + + const origin = this.buildDirectory('logs/peertube.log') + const destname = `peertube-${this.server.internalServerNumber}.log` + console.log('Saving logs %s.', destname) + + await copy(origin, join('artifacts', destname)) + } + + if (this.server.parallel) { + p.push(ServersCommand.flushTests(this.server.internalServerNumber)) + } + + if (this.server.customConfigFile) { + p.push(remove(this.server.customConfigFile)) + } + + return p + } + + async waitUntilLog (str: string, count = 1, strictCount = true) { + const logfile = this.buildDirectory('logs/peertube.log') + + while (true) { + const buf = await readFile(logfile) + + const matches = buf.toString().match(new RegExp(str, 'g')) + if (matches && matches.length === count) return + if (matches && strictCount === false && matches.length >= count) return + + await wait(1000) + } + } + + buildDirectory (directory: string) { + return join(root(), 'test' + this.server.internalServerNumber, directory) + } + + buildWebTorrentFilePath (fileUrl: string) { + return this.buildDirectory(join('videos', basename(fileUrl))) + } + + buildFragmentedFilePath (videoUUID: string, fileUrl: string) { + return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl))) + } + + getLogContent () { + return readFile(this.buildDirectory('logs/peertube.log')) + } + + async getServerFileSize (subPath: string) { + const path = this.server.servers.buildDirectory(subPath) + + return getFileSize(path) + } +} diff --git a/shared/server-commands/server/servers.ts b/shared/server-commands/server/servers.ts new file mode 100644 index 000000000..21ab9405b --- /dev/null +++ b/shared/server-commands/server/servers.ts @@ -0,0 +1,49 @@ +import { ensureDir } from 'fs-extra' +import { isGithubCI } from '../miscs' +import { PeerTubeServer, RunServerOptions } from './server' + +async function createSingleServer (serverNumber: number, configOverride?: Object, options: RunServerOptions = {}) { + const server = new PeerTubeServer({ serverNumber }) + + await server.flushAndRun(configOverride, options) + + return server +} + +function createMultipleServers (totalServers: number, configOverride?: Object, options: RunServerOptions = {}) { + const serverPromises: Promise[] = [] + + for (let i = 1; i <= totalServers; i++) { + serverPromises.push(createSingleServer(i, configOverride, options)) + } + + return Promise.all(serverPromises) +} + +async function killallServers (servers: PeerTubeServer[]) { + return Promise.all(servers.map(s => s.kill())) +} + +async function cleanupTests (servers: PeerTubeServer[]) { + await killallServers(servers) + + if (isGithubCI()) { + await ensureDir('artifacts') + } + + let p: Promise[] = [] + for (const server of servers) { + p = p.concat(server.servers.cleanupTests()) + } + + return Promise.all(p) +} + +// --------------------------------------------------------------------------- + +export { + createSingleServer, + createMultipleServers, + cleanupTests, + killallServers +} diff --git a/shared/server-commands/server/stats-command.ts b/shared/server-commands/server/stats-command.ts new file mode 100644 index 000000000..64a452306 --- /dev/null +++ b/shared/server-commands/server/stats-command.ts @@ -0,0 +1,25 @@ +import { HttpStatusCode, ServerStats } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class StatsCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + useCache?: boolean // default false + } = {}) { + const { useCache = false } = options + const path = '/api/v1/server/stats' + + const query = { + t: useCache ? undefined : new Date().getTime() + } + + return this.getRequestBody({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/server/tracker.ts b/shared/server-commands/server/tracker.ts new file mode 100644 index 000000000..ed43a5924 --- /dev/null +++ b/shared/server-commands/server/tracker.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { sha1 } from '@shared/core-utils/crypto' +import { makeGetRequest } from '../requests' + +async function hlsInfohashExist (serverUrl: string, masterPlaylistUrl: string, fileNumber: number) { + const path = '/tracker/announce' + + const infohash = sha1(`2${masterPlaylistUrl}+V${fileNumber}`) + + // From bittorrent-tracker + const infohashBinary = escape(Buffer.from(infohash, 'hex').toString('binary')).replace(/[@*/+]/g, function (char) { + return '%' + char.charCodeAt(0).toString(16).toUpperCase() + }) + + const res = await makeGetRequest({ + url: serverUrl, + path, + rawQuery: `peer_id=-WW0105-NkvYO/egUAr4&info_hash=${infohashBinary}&port=42100`, + expectedStatus: 200 + }) + + expect(res.text).to.not.contain('failure') +} + +export { + hlsInfohashExist +} diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts new file mode 100644 index 000000000..a57c857fc --- /dev/null +++ b/shared/server-commands/shared/abstract-command.ts @@ -0,0 +1,211 @@ +import { isAbsolute, join } from 'path' +import { root } from '../miscs/tests' +import { + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + makePutBodyRequest, + makeUploadRequest, + unwrapBody, + unwrapText +} from '../requests/requests' +import { PeerTubeServer } from '../server/server' + +export interface OverrideCommandOptions { + token?: string + expectedStatus?: number +} + +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: number + + // Common optional request parameters + contentType?: string + accept?: string + redirects?: number + range?: string + host?: string + headers?: { [ name: string ]: string } + requestType?: string + xForwardedFor?: string +} + +interface InternalGetCommandOptions extends InternalCommonCommandOptions { + query?: { [ id: string ]: any } +} + +interface InternalDeleteCommandOptions extends InternalCommonCommandOptions { + query?: { [ id: string ]: any } + rawQuery?: string +} + +abstract class AbstractCommand { + + constructor ( + protected server: PeerTubeServer + ) { + + } + + protected getRequestBody (options: InternalGetCommandOptions) { + return unwrapBody(this.getRequest(options)) + } + + protected getRequestText (options: InternalGetCommandOptions) { + 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: InternalGetCommandOptions) { + const { query } = options + + return makeGetRequest({ + ...this.buildCommonRequestOptions(options), + + query + }) + } + + protected deleteRequest (options: InternalDeleteCommandOptions) { + const { query, rawQuery } = options + + return makeDeleteRequest({ + ...this.buildCommonRequestOptions(options), + + query, + rawQuery + }) + } + + protected putBodyRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + }) { + const { fields } = options + + return makePutBodyRequest({ + ...this.buildCommonRequestOptions(options), + + fields + }) + } + + protected postBodyRequest (options: InternalCommonCommandOptions & { + fields?: { [ fieldName: string ]: any } + }) { + const { fields } = options + + return makePostBodyRequest({ + ...this.buildCommonRequestOptions(options), + + fields + }) + } + + 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 + : join(root(), 'server', 'tests', 'fixtures', options.fixture) + + return this.postUploadRequest({ + ...options, + + fields: {}, + attaches: { [options.fieldname]: filePath } + }) + } + + protected buildCommonRequestOptions (options: InternalCommonCommandOptions) { + const { url, path, redirects, contentType, accept, range, host, headers, requestType, xForwardedFor } = options + + return { + url: url ?? this.server.url, + path, + + token: this.buildCommonRequestToken(options), + expectedStatus: this.buildExpectedStatus(options), + + redirects, + contentType, + range, + host, + accept, + headers, + type: requestType, + xForwardedFor + } + } + + 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 + } +} + +export { + AbstractCommand +} diff --git a/shared/server-commands/shared/index.ts b/shared/server-commands/shared/index.ts new file mode 100644 index 000000000..e807ab4f7 --- /dev/null +++ b/shared/server-commands/shared/index.ts @@ -0,0 +1 @@ +export * from './abstract-command' diff --git a/shared/server-commands/socket/index.ts b/shared/server-commands/socket/index.ts new file mode 100644 index 000000000..594329b2f --- /dev/null +++ b/shared/server-commands/socket/index.ts @@ -0,0 +1 @@ +export * from './socket-io-command' diff --git a/shared/server-commands/socket/socket-io-command.ts b/shared/server-commands/socket/socket-io-command.ts new file mode 100644 index 000000000..c277ead28 --- /dev/null +++ b/shared/server-commands/socket/socket-io-command.ts @@ -0,0 +1,15 @@ +import { io } from 'socket.io-client' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class SocketIOCommand extends AbstractCommand { + + getUserNotificationSocket (options: OverrideCommandOptions = {}) { + return io(this.server.url + '/user-notifications', { + query: { accessToken: options.token ?? this.server.accessToken } + }) + } + + getLiveNotificationSocket () { + return io(this.server.url + '/live-videos') + } +} diff --git a/shared/server-commands/users/accounts-command.ts b/shared/server-commands/users/accounts-command.ts new file mode 100644 index 000000000..98d9d5927 --- /dev/null +++ b/shared/server-commands/users/accounts-command.ts @@ -0,0 +1,78 @@ +import { HttpStatusCode, ResultList } from '@shared/models' +import { Account, ActorFollow } from '../../models/actors' +import { AccountVideoRate, VideoRateType } from '../../models/videos' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class AccountsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/accounts' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + accountName: string + }) { + const path = '/api/v1/accounts/' + options.accountName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listRatings (options: OverrideCommandOptions & { + accountName: string + rating?: VideoRateType + }) { + const { rating, accountName } = options + const path = '/api/v1/accounts/' + accountName + '/ratings' + + const query = { rating } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFollowers (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { accountName, start, count, sort, search } = options + const path = '/api/v1/accounts/' + accountName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/users/actors.ts b/shared/server-commands/users/actors.ts new file mode 100644 index 000000000..12c3e078a --- /dev/null +++ b/shared/server-commands/users/actors.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists, readdir } from 'fs-extra' +import { join } from 'path' +import { root } from '@shared/core-utils' +import { Account, VideoChannel } from '@shared/models' +import { PeerTubeServer } from '../server' + +async function expectChannelsFollows (options: { + server: PeerTubeServer + handle: string + followers: number + following: number +}) { + const { server } = options + const { data } = await server.channels.list() + + return expectActorFollow({ ...options, data }) +} + +async function expectAccountFollows (options: { + server: PeerTubeServer + handle: string + followers: number + following: number +}) { + const { server } = options + const { data } = await server.accounts.list() + + return expectActorFollow({ ...options, data }) +} + +async function checkActorFilesWereRemoved (filename: string, serverNumber: number) { + const testDirectory = 'test' + serverNumber + + for (const directory of [ 'avatars' ]) { + const directoryPath = join(root(), testDirectory, directory) + + const directoryExists = await pathExists(directoryPath) + expect(directoryExists).to.be.true + + const files = await readdir(directoryPath) + for (const file of files) { + expect(file).to.not.contain(filename) + } + } +} + +export { + expectAccountFollows, + expectChannelsFollows, + checkActorFilesWereRemoved +} + +// --------------------------------------------------------------------------- + +function expectActorFollow (options: { + server: PeerTubeServer + data: (Account | VideoChannel)[] + handle: string + followers: number + following: number +}) { + const { server, data, handle, followers, following } = options + + const actor = data.find(a => a.name + '@' + a.host === handle) + const message = `${handle} on ${server.url}` + + expect(actor, message).to.exist + expect(actor.followersCount).to.equal(followers, message) + expect(actor.followingCount).to.equal(following, message) +} diff --git a/shared/server-commands/users/blocklist-command.ts b/shared/server-commands/users/blocklist-command.ts new file mode 100644 index 000000000..2e7ed074d --- /dev/null +++ b/shared/server-commands/users/blocklist-command.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +type ListBlocklistOptions = OverrideCommandOptions & { + start: number + count: number + sort: string // default -createdAt +} + +export class BlocklistCommand extends AbstractCommand { + + listMyAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listMyServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/servers' + + return this.listBlocklist(options, path) + } + + listServerAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listServerServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/servers' + + return this.listBlocklist(options, path) + } + + // --------------------------------------------------------------------------- + + getStatus (options: OverrideCommandOptions & { + accounts?: string[] + hosts?: string[] + }) { + const { accounts, hosts } = options + + const path = '/api/v1/blocklist/status' + + return this.getRequestBody({ + ...options, + + path, + query: { + accounts, + hosts + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + addToMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts' + : '/api/v1/users/me/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addToServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts' + : '/api/v1/server/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + removeFromMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts/' + account + : '/api/v1/users/me/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFromServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts/' + account + : '/api/v1/server/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + private listBlocklist (options: ListBlocklistOptions, path: string) { + const { start, count, sort = '-createdAt' } = options + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts new file mode 100644 index 000000000..460a06f70 --- /dev/null +++ b/shared/server-commands/users/index.ts @@ -0,0 +1,9 @@ +export * from './accounts-command' +export * from './actors' +export * from './blocklist-command' +export * from './login' +export * from './login-command' +export * from './notifications' +export * from './notifications-command' +export * from './subscriptions-command' +export * from './users-command' diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts new file mode 100644 index 000000000..143f72a59 --- /dev/null +++ b/shared/server-commands/users/login-command.ts @@ -0,0 +1,132 @@ +import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class LoginCommand extends AbstractCommand { + + login (options: OverrideCommandOptions & { + client?: { id?: string, secret?: string } + user?: { username: string, password?: string } + } = {}) { + const { client = this.server.store.client, user = this.server.store.user } = options + const path = '/api/v1/users/token' + + const body = { + client_id: client.id, + client_secret: client.secret, + username: user.username, + password: user.password ?? 'password', + response_type: 'code', + grant_type: 'password', + scope: 'upload' + } + + return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getAccessToken (arg1?: { username: string, password?: string }): Promise + getAccessToken (arg1: string, password?: string): Promise + async getAccessToken (arg1?: { username: string, password?: string } | string, password?: string) { + let user: { username: string, password?: string } + + if (!arg1) user = this.server.store.user + else if (typeof arg1 === 'object') user = arg1 + else user = { username: arg1, password } + + try { + const body = await this.login({ user }) + + return body.access_token + } catch (err) { + throw new Error(`Cannot authenticate. Please check your username/password. (${err})`) + } + } + + loginUsingExternalToken (options: OverrideCommandOptions & { + username: string + externalAuthToken: string + }) { + const { username, externalAuthToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + username: username, + response_type: 'code', + grant_type: 'password', + scope: 'upload', + externalAuthToken + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + logout (options: OverrideCommandOptions & { + token: string + }) { + const path = '/api/v1/users/revoke-token' + + return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + refreshToken (options: OverrideCommandOptions & { + refreshToken: string + }) { + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + refresh_token: options.refreshToken, + response_type: 'code', + grant_type: 'refresh_token' + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getClient (options: OverrideCommandOptions = {}) { + const path = '/api/v1/oauth-clients/local' + + return this.getRequestBody<{ client_id: string, client_secret: string }>({ + ...options, + + path, + host: this.server.host, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/users/login.ts b/shared/server-commands/users/login.ts new file mode 100644 index 000000000..f1df027d3 --- /dev/null +++ b/shared/server-commands/users/login.ts @@ -0,0 +1,19 @@ +import { PeerTubeServer } from '../server/server' + +function setAccessTokensToServers (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.login.getAccessToken() + .then(t => { server.accessToken = t }) + tasks.push(p) + } + + return Promise.all(tasks) +} + +// --------------------------------------------------------------------------- + +export { + setAccessTokensToServers +} diff --git a/shared/server-commands/users/notifications-command.ts b/shared/server-commands/users/notifications-command.ts new file mode 100644 index 000000000..692420b8b --- /dev/null +++ b/shared/server-commands/users/notifications-command.ts @@ -0,0 +1,86 @@ +import { HttpStatusCode, ResultList } from '@shared/models' +import { UserNotification, UserNotificationSetting } from '../../models/users' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class NotificationsCommand extends AbstractCommand { + + updateMySettings (options: OverrideCommandOptions & { + settings: UserNotificationSetting + }) { + const path = '/api/v1/users/me/notification-settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.settings, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + unread?: boolean + sort?: string + }) { + const { start, count, unread, sort = '-createdAt' } = options + const path = '/api/v1/users/me/notifications' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + unread + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + markAsRead (options: OverrideCommandOptions & { + ids: number[] + }) { + const { ids } = options + const path = '/api/v1/users/me/notifications/read' + + return this.postBodyRequest({ + ...options, + + path, + fields: { ids }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + markAsReadAll (options: OverrideCommandOptions) { + const path = '/api/v1/users/me/notifications/read-all' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async getLatest (options: OverrideCommandOptions = {}) { + const { total, data } = await this.list({ + ...options, + start: 0, + count: 1, + sort: '-createdAt' + }) + + if (total === 0) return undefined + + return data[0] + } +} diff --git a/shared/server-commands/users/notifications.ts b/shared/server-commands/users/notifications.ts new file mode 100644 index 000000000..07ccb0f8d --- /dev/null +++ b/shared/server-commands/users/notifications.ts @@ -0,0 +1,795 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { inspect } from 'util' +import { AbuseState, PluginType } from '@shared/models' +import { UserNotification, UserNotificationSetting, UserNotificationSettingValue, UserNotificationType } from '../../models/users' +import { MockSmtpServer } from '../mock-servers/mock-email' +import { PeerTubeServer } from '../server' +import { doubleFollow } from '../server/follows' +import { createMultipleServers } from '../server/servers' +import { setAccessTokensToServers } from './login' + +type CheckerBaseParams = { + server: PeerTubeServer + emails: any[] + socketNotifications: UserNotification[] + token: string + check?: { web: boolean, mail: boolean } +} + +type CheckerType = 'presence' | 'absence' + +function getAllNotificationsSettings (): UserNotificationSetting { + return { + newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } +} + +async function checkNewVideoFromSubscription (options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType +}) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION || n.video.name !== videoName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkVideoIsPublished (options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType +}) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + return text.includes(shortUUID) && text.includes('Your video') + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkMyVideoImportIsFinished (options: CheckerBaseParams & { + videoName: string + shortUUID: string + url: string + success: boolean + checkType: CheckerType +}) { + const { videoName, shortUUID, url, success } = options + + const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoImport.targetUrl).to.equal(url) + + if (success) checkVideo(notification.videoImport.video, videoName, shortUUID) + } else { + expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + const toFind = success ? ' finished' : ' error' + + return text.includes(url) && text.includes(toFind) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkUserRegistered (options: CheckerBaseParams & { + username: string + checkType: CheckerType +}) { + const { username } = options + const notificationType = UserNotificationType.NEW_USER_REGISTRATION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.account) + expect(notification.account.name).to.equal(username) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' registered.') && text.includes(username) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewActorFollow (options: CheckerBaseParams & { + followType: 'channel' | 'account' + followerName: string + followerDisplayName: string + followingDisplayName: string + checkType: CheckerType +}) { + const { followType, followerName, followerDisplayName, followingDisplayName } = options + const notificationType = UserNotificationType.NEW_FOLLOW + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.actorFollow.follower) + expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName) + expect(notification.actorFollow.follower.name).to.equal(followerName) + expect(notification.actorFollow.follower.host).to.not.be.undefined + + const following = notification.actorFollow.following + expect(following.displayName).to.equal(followingDisplayName) + expect(following.type).to.equal(followType) + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || + (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName) + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewInstanceFollower (options: CheckerBaseParams & { + followerHost: string + checkType: CheckerType +}) { + const { followerHost } = options + const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.actorFollow.follower) + expect(notification.actorFollow.follower.name).to.equal('peertube') + expect(notification.actorFollow.follower.host).to.equal(followerHost) + + expect(notification.actorFollow.following.name).to.equal('peertube') + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || n.actorFollow.follower.host !== followerHost + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes('instance has a new follower') && text.includes(followerHost) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkAutoInstanceFollowing (options: CheckerBaseParams & { + followerHost: string + followingHost: string + checkType: CheckerType +}) { + const { followerHost, followingHost } = options + const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + const following = notification.actorFollow.following + checkActor(following) + expect(following.name).to.equal('peertube') + expect(following.host).to.equal(followingHost) + + expect(notification.actorFollow.follower.name).to.equal('peertube') + expect(notification.actorFollow.follower.host).to.equal(followerHost) + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || n.actorFollow.following.host !== followingHost + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' automatically followed a new instance') && text.includes(followingHost) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkCommentMention (options: CheckerBaseParams & { + shortUUID: string + commentId: number + threadId: number + byAccountDisplayName: string + checkType: CheckerType +}) { + const { shortUUID, commentId, threadId, byAccountDisplayName } = options + const notificationType = UserNotificationType.COMMENT_MENTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + expect(notification.comment.account.displayName).to.equal(byAccountDisplayName) + + checkVideo(notification.comment.video, undefined, shortUUID) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' mentioned ') && text.includes(shortUUID) && text.includes(byAccountDisplayName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +let lastEmailCount = 0 + +async function checkNewCommentOnMyVideo (options: CheckerBaseParams & { + shortUUID: string + commentId: number + threadId: number + checkType: CheckerType +}) { + const { server, shortUUID, commentId, threadId, checkType, emails } = options + const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + checkVideo(notification.comment.video, undefined, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.comment === undefined || n.comment.id !== commentId + }) + } + } + + const commentUrl = `http://localhost:${server.port}/w/${shortUUID};threadId=${threadId}` + + function emailNotificationFinder (email: object) { + return email['text'].indexOf(commentUrl) !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) + + if (checkType === 'presence') { + // We cannot detect email duplicates, so check we received another email + expect(emails).to.have.length.above(lastEmailCount) + lastEmailCount = emails.length + } +} + +async function checkNewVideoAbuseForModerators (options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType +}) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + checkVideo(notification.abuse.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewAbuseMessage (options: CheckerBaseParams & { + abuseId: number + message: string + toEmail: string + checkType: CheckerType +}) { + const { abuseId, message, toEmail } = options + const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.equal(abuseId) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== notificationType || n.abuse === undefined || n.abuse.id !== abuseId + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + const to = email['to'].filter(t => t.address === toEmail) + + return text.indexOf(message) !== -1 && to.length !== 0 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkAbuseStateChange (options: CheckerBaseParams & { + abuseId: number + state: AbuseState + checkType: CheckerType +}) { + const { abuseId, state } = options + const notificationType = UserNotificationType.ABUSE_STATE_CHANGE + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.equal(abuseId) + expect(notification.abuse.state).to.equal(state) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.id !== abuseId + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + const contains = state === AbuseState.ACCEPTED + ? ' accepted' + : ' rejected' + + return text.indexOf(contains) !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewCommentAbuseForModerators (options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType +}) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + checkVideo(notification.abuse.comment.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.comment.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewAccountAbuseForModerators (options: CheckerBaseParams & { + displayName: string + checkType: CheckerType +}) { + const { displayName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + expect(notification.abuse.account.displayName).to.equal(displayName) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.abuse === undefined || n.abuse.account.displayName !== displayName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkVideoAutoBlacklistForModerators (options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType +}) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoBlacklist.video.id).to.be.a('number') + checkVideo(notification.videoBlacklist.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.video === undefined || n.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && email['text'].indexOf('video-auto-blacklist/list') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewBlacklistOnMyVideo (options: CheckerBaseParams & { + shortUUID: string + videoName: string + blacklistType: 'blacklist' | 'unblacklist' +}) { + const { videoName, shortUUID, blacklistType } = options + const notificationType = blacklistType === 'blacklist' + ? UserNotificationType.BLACKLIST_ON_MY_VIDEO + : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO + + function notificationChecker (notification: UserNotification) { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video + + checkVideo(video, videoName, shortUUID) + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + const blacklistText = blacklistType === 'blacklist' + ? 'blacklisted' + : 'unblacklisted' + + return text.includes(shortUUID) && text.includes(blacklistText) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' }) +} + +async function checkNewPeerTubeVersion (options: CheckerBaseParams & { + latestVersion: string + checkType: CheckerType +}) { + const { latestVersion } = options + const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.peertube).to.exist + expect(notification.peertube.latestVersion).to.equal(latestVersion) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.peertube === undefined || n.peertube.latestVersion !== latestVersion + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(latestVersion) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function checkNewPluginVersion (options: CheckerBaseParams & { + pluginType: PluginType + pluginName: string + checkType: CheckerType +}) { + const { pluginName, pluginType } = options + const notificationType = UserNotificationType.NEW_PLUGIN_VERSION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.plugin.name).to.equal(pluginName) + expect(notification.plugin.type).to.equal(pluginType) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.plugin === undefined || n.plugin.name !== pluginName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(pluginName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: any = {}) { + const userNotifications: UserNotification[] = [] + const adminNotifications: UserNotification[] = [] + const adminNotificationsServer2: UserNotification[] = [] + const emails: object[] = [] + + const port = await MockSmtpServer.Instance.collectEmails(emails) + + const overrideConfig = { + smtp: { + hostname: 'localhost', + port + }, + signup: { + limit: 20 + } + } + const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) + + await setAccessTokensToServers(servers) + + if (serversCount > 1) { + await doubleFollow(servers[0], servers[1]) + } + + const user = { username: 'user_1', password: 'super password' } + await servers[0].users.create({ ...user, videoQuota: 10 * 1000 * 1000 }) + const userAccessToken = await servers[0].login.getAccessToken(user) + + await servers[0].notifications.updateMySettings({ token: userAccessToken, settings: getAllNotificationsSettings() }) + await servers[0].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) + + if (serversCount > 1) { + await servers[1].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) + } + + { + const socket = servers[0].socketIO.getUserNotificationSocket({ token: userAccessToken }) + socket.on('new-notification', n => userNotifications.push(n)) + } + { + const socket = servers[0].socketIO.getUserNotificationSocket() + socket.on('new-notification', n => adminNotifications.push(n)) + } + + if (serversCount > 1) { + const socket = servers[1].socketIO.getUserNotificationSocket() + socket.on('new-notification', n => adminNotificationsServer2.push(n)) + } + + const { videoChannels } = await servers[0].users.getMyInfo() + const channelId = videoChannels[0].id + + return { + userNotifications, + adminNotifications, + adminNotificationsServer2, + userAccessToken, + emails, + servers, + channelId + } +} + +// --------------------------------------------------------------------------- + +export { + getAllNotificationsSettings, + + CheckerBaseParams, + CheckerType, + checkMyVideoImportIsFinished, + checkUserRegistered, + checkAutoInstanceFollowing, + checkVideoIsPublished, + checkNewVideoFromSubscription, + checkNewActorFollow, + checkNewCommentOnMyVideo, + checkNewBlacklistOnMyVideo, + checkCommentMention, + checkNewVideoAbuseForModerators, + checkVideoAutoBlacklistForModerators, + checkNewAbuseMessage, + checkAbuseStateChange, + checkNewInstanceFollower, + prepareNotificationsTest, + checkNewCommentAbuseForModerators, + checkNewAccountAbuseForModerators, + checkNewPeerTubeVersion, + checkNewPluginVersion +} + +// --------------------------------------------------------------------------- + +async function checkNotification (options: CheckerBaseParams & { + notificationChecker: (notification: UserNotification, checkType: CheckerType) => void + emailNotificationFinder: (email: object) => boolean + checkType: CheckerType +}) { + const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options + + const check = options.check || { web: true, mail: true } + + if (check.web) { + const notification = await server.notifications.getLatest({ token: token }) + + if (notification || checkType !== 'absence') { + notificationChecker(notification, checkType) + } + + const socketNotification = socketNotifications.find(n => { + try { + notificationChecker(n, 'presence') + return true + } catch { + return false + } + }) + + if (checkType === 'presence') { + const obj = inspect(socketNotifications, { depth: 5 }) + expect(socketNotification, 'The socket notification is absent when it should be present. ' + obj).to.not.be.undefined + } else { + const obj = inspect(socketNotification, { depth: 5 }) + expect(socketNotification, 'The socket notification is present when it should not be present. ' + obj).to.be.undefined + } + } + + if (check.mail) { + // Last email + const email = emails + .slice() + .reverse() + .find(e => emailNotificationFinder(e)) + + if (checkType === 'presence') { + const texts = emails.map(e => e.text) + expect(email, 'The email is absent when is should be present. ' + inspect(texts)).to.not.be.undefined + } else { + expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined + } + } +} + +function checkVideo (video: any, videoName?: string, shortUUID?: string) { + if (videoName) { + expect(video.name).to.be.a('string') + expect(video.name).to.not.be.empty + expect(video.name).to.equal(videoName) + } + + if (shortUUID) { + expect(video.shortUUID).to.be.a('string') + expect(video.shortUUID).to.not.be.empty + expect(video.shortUUID).to.equal(shortUUID) + } + + expect(video.id).to.be.a('number') +} + +function checkActor (actor: any) { + expect(actor.displayName).to.be.a('string') + expect(actor.displayName).to.not.be.empty + expect(actor.host).to.not.be.undefined +} + +function checkComment (comment: any, commentId: number, threadId: number) { + expect(comment.id).to.equal(commentId) + expect(comment.threadId).to.equal(threadId) +} diff --git a/shared/server-commands/users/subscriptions-command.ts b/shared/server-commands/users/subscriptions-command.ts new file mode 100644 index 000000000..edc60e612 --- /dev/null +++ b/shared/server-commands/users/subscriptions-command.ts @@ -0,0 +1,99 @@ +import { HttpStatusCode, ResultList, Video, VideoChannel } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class SubscriptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + targetUri: string + }) { + const path = '/api/v1/users/me/subscriptions' + + return this.postBodyRequest({ + ...options, + + path, + fields: { uri: options.targetUri }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + search?: string + } = {}) { + const { sort = '-createdAt', search } = options + const path = '/api/v1/users/me/subscriptions' + + return this.getRequestBody>({ + ...options, + + path, + query: { + sort, + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listVideos (options: OverrideCommandOptions & { + sort?: string // default -createdAt + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/users/me/subscriptions/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + exist (options: OverrideCommandOptions & { + uris: string[] + }) { + const path = '/api/v1/users/me/subscriptions/exist' + + return this.getRequestBody<{ [id: string ]: boolean }>({ + ...options, + + path, + query: { 'uris[]': options.uris }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts new file mode 100644 index 000000000..90c5f2183 --- /dev/null +++ b/shared/server-commands/users/users-command.ts @@ -0,0 +1,416 @@ +import { omit } from 'lodash' +import { pick } from '@shared/core-utils' +import { + HttpStatusCode, + MyUser, + ResultList, + User, + UserAdminFlag, + UserCreateResult, + UserRole, + UserUpdate, + UserUpdateMe, + UserVideoQuota, + UserVideoRate +} from '@shared/models' +import { ScopedToken } from '@shared/models/users/user-scoped-token' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class UsersCommand extends AbstractCommand { + + askResetPassword (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + resetPassword (options: OverrideCommandOptions & { + userId: number + verificationString: string + password: string + }) { + const { userId, verificationString, password } = options + const path = '/api/v1/users/' + userId + '/reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { password, verificationString }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + userId: number + verificationString: string + isPendingEmail?: boolean // default false + }) { + const { userId, verificationString, isPendingEmail = false } = options + const path = '/api/v1/users/' + userId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString, + isPendingEmail + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + banUser (options: OverrideCommandOptions & { + userId: number + reason?: string + }) { + const { userId, reason } = options + const path = '/api/v1/users' + '/' + userId + '/block' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + unbanUser (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users' + '/' + userId + '/unblock' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + getMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + renewMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + create (options: OverrideCommandOptions & { + username: string + password?: string + videoQuota?: number + videoQuotaDaily?: number + role?: UserRole + adminFlags?: UserAdminFlag + }) { + const { + username, + adminFlags, + password = 'password', + videoQuota = 42000000, + videoQuotaDaily = -1, + role = UserRole.USER + } = options + + const path = '/api/v1/users' + + return unwrapBody<{ user: UserCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: { + username, + password, + role, + adminFlags, + email: username + '@example.com', + videoQuota, + videoQuotaDaily + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(res => res.user) + } + + async generate (username: string, role?: UserRole) { + const password = 'password' + const user = await this.create({ username, password, role }) + + const token = await this.server.login.getAccessToken({ username, password }) + + const me = await this.getMyInfo({ token }) + + return { + token, + userId: user.id, + userChannelId: me.videoChannels[0].id, + userChannelName: me.videoChannels[0].name + } + } + + async generateUserAndToken (username: string, role?: UserRole) { + const password = 'password' + await this.create({ username, password, role }) + + return this.server.login.getAccessToken({ username, password }) + } + + register (options: OverrideCommandOptions & { + username: string + password?: string + displayName?: string + channel?: { + name: string + displayName: string + } + }) { + const { username, password = 'password', displayName, channel } = options + const path = '/api/v1/users/register' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + username, + password, + email: username + '@example.com', + displayName, + channel + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + getMyInfo (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyQuotaUsed (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me/video-quota-used' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyRating (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/users/me/videos/' + videoId + '/rating' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMe (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMe (options: OverrideCommandOptions & UserUpdateMe) { + const path = '/api/v1/users/me' + + const toSend: UserUpdateMe = omit(options, 'url', 'accessToken') + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMyAvatar (options: OverrideCommandOptions & { + fixture: string + }) { + const { fixture } = options + const path = '/api/v1/users/me/avatar/pick' + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: 'avatarfile', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + userId: number + withStats?: boolean // default false + }) { + const { userId, withStats } = options + const path = '/api/v1/users/' + userId + + return this.getRequestBody({ + ...options, + + path, + query: { withStats }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + blocked?: boolean + } = {}) { + const path = '/api/v1/users' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'blocked' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users/' + userId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + userId: number + email?: string + emailVerified?: boolean + videoQuota?: number + videoQuotaDaily?: number + password?: string + adminFlags?: UserAdminFlag + pluginAuth?: string + role?: UserRole + }) { + const path = '/api/v1/users/' + options.userId + + const toSend: UserUpdate = {} + if (options.password !== undefined && options.password !== null) toSend.password = options.password + if (options.email !== undefined && options.email !== null) toSend.email = options.email + if (options.emailVerified !== undefined && options.emailVerified !== null) toSend.emailVerified = options.emailVerified + if (options.videoQuota !== undefined && options.videoQuota !== null) toSend.videoQuota = options.videoQuota + if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend.videoQuotaDaily = options.videoQuotaDaily + if (options.role !== undefined && options.role !== null) toSend.role = options.role + if (options.adminFlags !== undefined && options.adminFlags !== null) toSend.adminFlags = options.adminFlags + if (options.pluginAuth !== undefined) toSend.pluginAuth = options.pluginAuth + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/videos/blacklist-command.ts b/shared/server-commands/videos/blacklist-command.ts new file mode 100644 index 000000000..3a2ef89ba --- /dev/null +++ b/shared/server-commands/videos/blacklist-command.ts @@ -0,0 +1,76 @@ + +import { HttpStatusCode, ResultList } from '@shared/models' +import { VideoBlacklist, VideoBlacklistType } from '../../models/videos' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class BlacklistCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + videoId: number | string + reason?: string + unfederate?: boolean + }) { + const { videoId, reason, unfederate } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason, unfederate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + videoId: number | string + reason?: string + }) { + const { videoId, reason } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.putBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/videos/' + videoId + '/blacklist' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string + type?: VideoBlacklistType + } = {}) { + const { sort, type } = options + const path = '/api/v1/videos/blacklist/' + + const query = { sort, type } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts new file mode 100644 index 000000000..a65ea99e3 --- /dev/null +++ b/shared/server-commands/videos/captions-command.ts @@ -0,0 +1,65 @@ +import { HttpStatusCode, ResultList, VideoCaption } from '@shared/models' +import { buildAbsoluteFixturePath } from '../miscs' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class CaptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + videoId: string | number + language: string + fixture: string + mimeType?: string + }) { + const { videoId, language, fixture, mimeType } = options + + const path = '/api/v1/videos/' + videoId + '/captions/' + language + + const captionfile = buildAbsoluteFixturePath(fixture) + const captionfileAttach = mimeType + ? [ captionfile, { contentType: mimeType } ] + : captionfile + + return this.putUploadRequest({ + ...options, + + path, + fields: {}, + attaches: { + captionfile: captionfileAttach + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + videoId: string | number + }) { + const { videoId } = options + const path = '/api/v1/videos/' + videoId + '/captions' + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + delete (options: OverrideCommandOptions & { + videoId: string | number + language: string + }) { + const { videoId, language } = options + const path = '/api/v1/videos/' + videoId + '/captions/' + language + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/videos/captions.ts b/shared/server-commands/videos/captions.ts new file mode 100644 index 000000000..35e722408 --- /dev/null +++ b/shared/server-commands/videos/captions.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai' +import request from 'supertest' +import { HttpStatusCode } from '@shared/models' + +async function testCaptionFile (url: string, captionPath: string, toTest: RegExp | string) { + const res = await request(url) + .get(captionPath) + .expect(HttpStatusCode.OK_200) + + if (toTest instanceof RegExp) { + expect(res.text).to.match(toTest) + } else { + expect(res.text).to.contain(toTest) + } +} + +// --------------------------------------------------------------------------- + +export { + testCaptionFile +} diff --git a/shared/server-commands/videos/change-ownership-command.ts b/shared/server-commands/videos/change-ownership-command.ts new file mode 100644 index 000000000..ad4c726ef --- /dev/null +++ b/shared/server-commands/videos/change-ownership-command.ts @@ -0,0 +1,68 @@ + +import { HttpStatusCode, ResultList, VideoChangeOwnership } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ChangeOwnershipCommand extends AbstractCommand { + + create (options: OverrideCommandOptions & { + videoId: number | string + username: string + }) { + const { videoId, username } = options + const path = '/api/v1/videos/' + videoId + '/give-ownership' + + return this.postBodyRequest({ + ...options, + + path, + fields: { username }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/ownership' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort: '-createdAt' }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + accept (options: OverrideCommandOptions & { + ownershipId: number + channelId: number + }) { + const { ownershipId, channelId } = options + const path = '/api/v1/videos/ownership/' + ownershipId + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + fields: { channelId }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + refuse (options: OverrideCommandOptions & { + ownershipId: number + }) { + const { ownershipId } = options + const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/videos/channels-command.ts b/shared/server-commands/videos/channels-command.ts new file mode 100644 index 000000000..e406e570b --- /dev/null +++ b/shared/server-commands/videos/channels-command.ts @@ -0,0 +1,178 @@ +import { pick } from '@shared/core-utils' +import { ActorFollow, HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models' +import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' +import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ChannelsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + withStats?: boolean + } = {}) { + const path = '/api/v1/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'withStats' ]), + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByAccount (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + withStats?: boolean + search?: string + }) { + const { accountName, sort = 'createdAt' } = options + const path = '/api/v1/accounts/' + accountName + '/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: Partial + }) { + const path = '/api/v1/video-channels/' + + // Default attributes + const defaultAttributes = { + displayName: 'my super video channel', + description: 'my super channel description', + support: 'my super channel support' + } + const attributes = { ...defaultAttributes, ...options.attributes } + + const body = await unwrapBody<{ videoChannel: VideoChannelCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoChannel + } + + update (options: OverrideCommandOptions & { + channelName: string + attributes: VideoChannelUpdate + }) { + const { channelName, attributes } = options + const path = '/api/v1/video-channels/' + channelName + + return this.putBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + delete (options: OverrideCommandOptions & { + channelName: string + }) { + const path = '/api/v1/video-channels/' + options.channelName + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + get (options: OverrideCommandOptions & { + channelName: string + }) { + const path = '/api/v1/video-channels/' + options.channelName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateImage (options: OverrideCommandOptions & { + fixture: string + channelName: string | number + type: 'avatar' | 'banner' + }) { + const { channelName, fixture, type } = options + + const path = `/api/v1/video-channels/${channelName}/${type}/pick` + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: type + 'file', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteImage (options: OverrideCommandOptions & { + channelName: string | number + type: 'avatar' | 'banner' + }) { + const { channelName, type } = options + + const path = `/api/v1/video-channels/${channelName}/${type}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + listFollowers (options: OverrideCommandOptions & { + channelName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { channelName, start, count, sort, search } = options + const path = '/api/v1/video-channels/' + channelName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/videos/channels.ts b/shared/server-commands/videos/channels.ts new file mode 100644 index 000000000..756c47453 --- /dev/null +++ b/shared/server-commands/videos/channels.ts @@ -0,0 +1,18 @@ +import { PeerTubeServer } from '../server/server' + +function setDefaultVideoChannel (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.users.getMyInfo() + .then(user => { server.store.channel = user.videoChannels[0] }) + + tasks.push(p) + } + + return Promise.all(tasks) +} + +export { + setDefaultVideoChannel +} diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts new file mode 100644 index 000000000..f0d163a07 --- /dev/null +++ b/shared/server-commands/videos/comments-command.ts @@ -0,0 +1,152 @@ +import { pick } from 'lodash' +import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@shared/models' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class CommentsCommand extends AbstractCommand { + + private lastVideoId: number | string + private lastThreadId: number + private lastReplyId: number + + listForAdmin (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + isLocal?: boolean + search?: string + searchAccount?: string + searchVideo?: string + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/videos/comments' + + const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'search', 'searchAccount', 'searchVideo' ]) } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listThreads (options: OverrideCommandOptions & { + videoId: number | string + start?: number + count?: number + sort?: string + }) { + const { start, count, sort, videoId } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads' + + return this.getRequestBody({ + ...options, + + path, + query: { start, count, sort }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getThread (options: OverrideCommandOptions & { + videoId: number | string + threadId: number + }) { + const { videoId, threadId } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async createThread (options: OverrideCommandOptions & { + videoId: number | string + text: string + }) { + const { videoId, text } = options + const path = '/api/v1/videos/' + videoId + '/comment-threads' + + const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ + ...options, + + path, + fields: { text }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + this.lastThreadId = body.comment?.id + this.lastVideoId = videoId + + return body.comment + } + + async addReply (options: OverrideCommandOptions & { + videoId: number | string + toCommentId: number + text: string + }) { + const { videoId, toCommentId, text } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId + + const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ + ...options, + + path, + fields: { text }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + this.lastReplyId = body.comment?.id + + return body.comment + } + + async addReplyToLastReply (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId }) + } + + async addReplyToLastThread (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId }) + } + + async findCommentId (options: OverrideCommandOptions & { + videoId: number | string + text: string + }) { + const { videoId, text } = options + const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' }) + + return data.find(c => c.text === text).id + } + + delete (options: OverrideCommandOptions & { + videoId: number | string + commentId: number + }) { + const { videoId, commentId } = options + const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/videos/history-command.ts b/shared/server-commands/videos/history-command.ts new file mode 100644 index 000000000..13b7150c1 --- /dev/null +++ b/shared/server-commands/videos/history-command.ts @@ -0,0 +1,58 @@ +import { HttpStatusCode, ResultList, Video } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class HistoryCommand extends AbstractCommand { + + wathVideo (options: OverrideCommandOptions & { + videoId: number | string + currentTime: number + }) { + const { videoId, currentTime } = options + + const path = '/api/v1/videos/' + videoId + '/watching' + const fields = { currentTime } + + return this.putBodyRequest({ + ...options, + + path, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + search?: string + } = {}) { + const { search } = options + const path = '/api/v1/users/me/history/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + beforeDate?: string + } = {}) { + const { beforeDate } = options + const path = '/api/v1/users/me/history/videos/remove' + + return this.postBodyRequest({ + ...options, + + path, + fields: { beforeDate }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/videos/imports-command.ts b/shared/server-commands/videos/imports-command.ts new file mode 100644 index 000000000..e4944694d --- /dev/null +++ b/shared/server-commands/videos/imports-command.ts @@ -0,0 +1,47 @@ + +import { HttpStatusCode, ResultList } from '@shared/models' +import { VideoImport, VideoImportCreate } from '../../models/videos' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ImportsCommand extends AbstractCommand { + + importVideo (options: OverrideCommandOptions & { + attributes: VideoImportCreate & { torrentfile?: string } + }) { + const { attributes } = options + const path = '/api/v1/videos/imports' + + let attaches: any = {} + if (attributes.torrentfile) attaches = { torrentfile: attributes.torrentfile } + + return unwrapBody(this.postUploadRequest({ + ...options, + + path, + attaches, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getMyVideoImports (options: OverrideCommandOptions & { + sort?: string + } = {}) { + const { sort } = options + const path = '/api/v1/users/me/videos/imports' + + const query = {} + if (sort) query['sort'] = sort + + return this.getRequestBody>({ + ...options, + + path, + query: { sort }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts new file mode 100644 index 000000000..26e663f46 --- /dev/null +++ b/shared/server-commands/videos/index.ts @@ -0,0 +1,19 @@ +export * from './blacklist-command' +export * from './captions-command' +export * from './captions' +export * from './change-ownership-command' +export * from './channels' +export * from './channels-command' +export * from './comments-command' +export * from './history-command' +export * from './imports-command' +export * from './live-command' +export * from './live' +export * from './playlists-command' +export * from './playlists' +export * from './services-command' +export * from './streaming-playlists-command' +export * from './streaming-playlists' +export * from './comments-command' +export * from './videos-command' +export * from './videos' diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts new file mode 100644 index 000000000..74f5d3089 --- /dev/null +++ b/shared/server-commands/videos/live-command.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { readdir } from 'fs-extra' +import { omit } from 'lodash' +import { join } from 'path' +import { HttpStatusCode, LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoCreateResult, VideoDetails, VideoState } from '@shared/models' +import { wait } from '../miscs' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' +import { sendRTMPStream, testFfmpegStreamError } from './live' + +export class LiveCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/live' + + return this.getRequestBody({ + ...options, + + path: path + '/' + options.videoId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & { + videoId: number | string + fields: LiveVideoUpdate + }) { + const { videoId, fields } = options + const path = '/api/v1/videos/live' + + return this.putBodyRequest({ + ...options, + + path: path + '/' + videoId, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async create (options: OverrideCommandOptions & { + fields: LiveVideoCreate + }) { + const { fields } = options + const path = '/api/v1/videos/live' + + const attaches: any = {} + if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile + if (fields.previewfile) attaches.previewfile = fields.previewfile + + const body = await unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ + ...options, + + path, + attaches, + fields: omit(fields, 'thumbnailfile', 'previewfile'), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.video + } + + async sendRTMPStreamInVideo (options: OverrideCommandOptions & { + videoId: number | string + fixtureName?: string + copyCodecs?: boolean + }) { + const { videoId, fixtureName, copyCodecs } = options + const videoLive = await this.get({ videoId }) + + return sendRTMPStream({ rtmpBaseUrl: videoLive.rtmpUrl, streamKey: videoLive.streamKey, fixtureName, copyCodecs }) + } + + async runAndTestStreamError (options: OverrideCommandOptions & { + videoId: number | string + shouldHaveError: boolean + }) { + const command = await this.sendRTMPStreamInVideo(options) + + return testFfmpegStreamError(command, options.shouldHaveError) + } + + waitUntilPublished (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.PUBLISHED }) + } + + waitUntilWaiting (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.WAITING_FOR_LIVE }) + } + + waitUntilEnded (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + return this.waitUntilState({ videoId, state: VideoState.LIVE_ENDED }) + } + + waitUntilSegmentGeneration (options: OverrideCommandOptions & { + videoUUID: string + resolution: number + segment: number + }) { + const { resolution, segment, videoUUID } = options + const segmentName = `${resolution}-00000${segment}.ts` + + return this.server.servers.waitUntilLog(`${videoUUID}/${segmentName}`, 2, false) + } + + async waitUntilSaved (options: OverrideCommandOptions & { + videoId: number | string + }) { + let video: VideoDetails + + do { + video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) + + await wait(500) + } while (video.isLive === true || video.state.id !== VideoState.PUBLISHED) + } + + async countPlaylists (options: OverrideCommandOptions & { + videoUUID: string + }) { + const basePath = this.server.servers.buildDirectory('streaming-playlists') + const hlsPath = join(basePath, 'hls', options.videoUUID) + + const files = await readdir(hlsPath) + + return files.filter(f => f.endsWith('.m3u8')).length + } + + private async waitUntilState (options: OverrideCommandOptions & { + videoId: number | string + state: VideoState + }) { + let video: VideoDetails + + do { + video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId }) + + await wait(500) + } while (video.state.id !== options.state) + } +} diff --git a/shared/server-commands/videos/live.ts b/shared/server-commands/videos/live.ts new file mode 100644 index 000000000..d3665bc90 --- /dev/null +++ b/shared/server-commands/videos/live.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' +import { pathExists, readdir } from 'fs-extra' +import { join } from 'path' +import { buildAbsoluteFixturePath, wait } from '../miscs' +import { PeerTubeServer } from '../server/server' + +function sendRTMPStream (options: { + rtmpBaseUrl: string + streamKey: string + fixtureName?: string // default video_short.mp4 + copyCodecs?: boolean // default false +}) { + const { rtmpBaseUrl, streamKey, fixtureName = 'video_short.mp4', copyCodecs = false } = options + + const fixture = buildAbsoluteFixturePath(fixtureName) + + const command = ffmpeg(fixture) + command.inputOption('-stream_loop -1') + command.inputOption('-re') + + if (copyCodecs) { + command.outputOption('-c copy') + } else { + command.outputOption('-c:v libx264') + command.outputOption('-g 50') + command.outputOption('-keyint_min 2') + command.outputOption('-r 60') + } + + command.outputOption('-f flv') + + const rtmpUrl = rtmpBaseUrl + '/' + streamKey + command.output(rtmpUrl) + + command.on('error', err => { + if (err?.message?.includes('Exiting normally')) return + + if (process.env.DEBUG) console.error(err) + }) + + if (process.env.DEBUG) { + command.on('stderr', data => console.log(data)) + } + + command.run() + + return command +} + +function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) { + return new Promise((res, rej) => { + command.on('error', err => { + return rej(err) + }) + + setTimeout(() => { + res() + }, successAfterMS) + }) +} + +async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) { + let error: Error + + try { + await waitFfmpegUntilError(command, 35000) + } catch (err) { + error = err + } + + await stopFfmpeg(command) + + if (shouldHaveError && !error) throw new Error('Ffmpeg did not have an error') + if (!shouldHaveError && error) throw error +} + +async function stopFfmpeg (command: FfmpegCommand) { + command.kill('SIGINT') + + await wait(500) +} + +async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilPublished({ videoId }) + } +} + +async function waitUntilLiveSavedOnAllServers (servers: PeerTubeServer[], videoId: string) { + for (const server of servers) { + await server.live.waitUntilSaved({ videoId }) + } +} + +async function checkLiveCleanupAfterSave (server: PeerTubeServer, videoUUID: string, resolutions: number[] = []) { + const basePath = server.servers.buildDirectory('streaming-playlists') + const hlsPath = join(basePath, 'hls', videoUUID) + + if (resolutions.length === 0) { + const result = await pathExists(hlsPath) + expect(result).to.be.false + + return + } + + const files = await readdir(hlsPath) + + // fragmented file and playlist per resolution + master playlist + segments sha256 json file + expect(files).to.have.lengthOf(resolutions.length * 2 + 2) + + for (const resolution of resolutions) { + const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`)) + expect(fragmentedFile).to.exist + + const playlistFile = files.find(f => f.endsWith(`${resolution}.m3u8`)) + expect(playlistFile).to.exist + } + + const masterPlaylistFile = files.find(f => f.endsWith('-master.m3u8')) + expect(masterPlaylistFile).to.exist + + const shaFile = files.find(f => f.endsWith('-segments-sha256.json')) + expect(shaFile).to.exist +} + +export { + sendRTMPStream, + waitFfmpegUntilError, + testFfmpegStreamError, + stopFfmpeg, + waitUntilLivePublishedOnAllServers, + waitUntilLiveSavedOnAllServers, + checkLiveCleanupAfterSave +} diff --git a/shared/server-commands/videos/playlists-command.ts b/shared/server-commands/videos/playlists-command.ts new file mode 100644 index 000000000..ce23900d3 --- /dev/null +++ b/shared/server-commands/videos/playlists-command.ts @@ -0,0 +1,280 @@ +import { omit } from 'lodash' +import { pick } from '@shared/core-utils' +import { + BooleanBothQuery, + HttpStatusCode, + ResultList, + VideoExistInPlaylist, + VideoPlaylist, + VideoPlaylistCreate, + VideoPlaylistCreateResult, + VideoPlaylistElement, + VideoPlaylistElementCreate, + VideoPlaylistElementCreateResult, + VideoPlaylistElementUpdate, + VideoPlaylistReorder, + VideoPlaylistType, + VideoPlaylistUpdate +} from '@shared/models' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class PlaylistsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + }) { + const path = '/api/v1/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByChannel (options: OverrideCommandOptions & { + handle: string + start?: number + count?: number + sort?: string + }) { + const path = '/api/v1/video-channels/' + options.handle + '/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByAccount (options: OverrideCommandOptions & { + handle: string + start?: number + count?: number + sort?: string + search?: string + playlistType?: VideoPlaylistType + }) { + const path = '/api/v1/accounts/' + options.handle + '/video-playlists' + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ]) + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + playlistId: number | string + }) { + const { playlistId } = options + const path = '/api/v1/video-playlists/' + playlistId + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listVideos (options: OverrideCommandOptions & { + playlistId: number | string + start?: number + count?: number + query?: { nsfw?: BooleanBothQuery } + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' + const query = options.query ?? {} + + return this.getRequestBody>({ + ...options, + + path, + query: { + ...query, + start: options.start, + count: options.count + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + delete (options: OverrideCommandOptions & { + playlistId: number | string + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async create (options: OverrideCommandOptions & { + attributes: VideoPlaylistCreate + }) { + const path = '/api/v1/video-playlists' + + const fields = omit(options.attributes, 'thumbnailfile') + + const attaches = options.attributes.thumbnailfile + ? { thumbnailfile: options.attributes.thumbnailfile } + : {} + + const body = await unwrapBody<{ videoPlaylist: VideoPlaylistCreateResult }>(this.postUploadRequest({ + ...options, + + path, + fields, + attaches, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoPlaylist + } + + update (options: OverrideCommandOptions & { + attributes: VideoPlaylistUpdate + playlistId: number | string + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + + const fields = omit(options.attributes, 'thumbnailfile') + + const attaches = options.attributes.thumbnailfile + ? { thumbnailfile: options.attributes.thumbnailfile } + : {} + + return this.putUploadRequest({ + ...options, + + path, + fields, + attaches, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async addElement (options: OverrideCommandOptions & { + playlistId: number | string + attributes: VideoPlaylistElementCreate | { videoId: string } + }) { + const attributes = { + ...options.attributes, + + videoId: await this.server.videos.getId({ ...options, uuid: options.attributes.videoId }) + } + + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos' + + const body = await unwrapBody<{ videoPlaylistElement: VideoPlaylistElementCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.videoPlaylistElement + } + + updateElement (options: OverrideCommandOptions & { + playlistId: number | string + elementId: number | string + attributes: VideoPlaylistElementUpdate + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId + + return this.putBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeElement (options: OverrideCommandOptions & { + playlistId: number | string + elementId: number + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reorderElements (options: OverrideCommandOptions & { + playlistId: number | string + attributes: VideoPlaylistReorder + }) { + const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder' + + return this.postBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + getPrivacies (options: OverrideCommandOptions = {}) { + const path = '/api/v1/video-playlists/privacies' + + return this.getRequestBody<{ [ id: number ]: string }>({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + videosExist (options: OverrideCommandOptions & { + videoIds: number[] + }) { + const { videoIds } = options + const path = '/api/v1/users/me/video-playlists/videos-exist' + + return this.getRequestBody({ + ...options, + + path, + query: { videoIds }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/videos/playlists.ts b/shared/server-commands/videos/playlists.ts new file mode 100644 index 000000000..3dde52bb9 --- /dev/null +++ b/shared/server-commands/videos/playlists.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai' +import { readdir } from 'fs-extra' +import { join } from 'path' +import { root } from '../miscs' + +async function checkPlaylistFilesWereRemoved ( + playlistUUID: string, + internalServerNumber: number, + directories = [ 'thumbnails' ] +) { + const testDirectory = 'test' + internalServerNumber + + for (const directory of directories) { + const directoryPath = join(root(), testDirectory, directory) + + const files = await readdir(directoryPath) + for (const file of files) { + expect(file).to.not.contain(playlistUUID) + } + } +} + +export { + checkPlaylistFilesWereRemoved +} diff --git a/shared/server-commands/videos/services-command.ts b/shared/server-commands/videos/services-command.ts new file mode 100644 index 000000000..06760df42 --- /dev/null +++ b/shared/server-commands/videos/services-command.ts @@ -0,0 +1,29 @@ +import { HttpStatusCode } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class ServicesCommand extends AbstractCommand { + + getOEmbed (options: OverrideCommandOptions & { + oembedUrl: string + format?: string + maxHeight?: number + maxWidth?: number + }) { + const path = '/services/oembed' + const query = { + url: options.oembedUrl, + format: options.format, + maxheight: options.maxHeight, + maxwidth: options.maxWidth + } + + return this.getRequest({ + ...options, + + path, + query, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/shared/server-commands/videos/streaming-playlists-command.ts b/shared/server-commands/videos/streaming-playlists-command.ts new file mode 100644 index 000000000..5d40d35cb --- /dev/null +++ b/shared/server-commands/videos/streaming-playlists-command.ts @@ -0,0 +1,44 @@ +import { HttpStatusCode } from '@shared/models' +import { unwrapBody, unwrapTextOrDecode, unwrapBodyOrDecodeToJSON } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class StreamingPlaylistsCommand extends AbstractCommand { + + get (options: OverrideCommandOptions & { + url: string + }) { + return unwrapTextOrDecode(this.getRawRequest({ + ...options, + + url: options.url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getSegment (options: OverrideCommandOptions & { + url: string + range?: string + }) { + return unwrapBody(this.getRawRequest({ + ...options, + + url: options.url, + range: options.range, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + getSegmentSha256 (options: OverrideCommandOptions & { + url: string + }) { + return unwrapBodyOrDecodeToJSON<{ [ id: string ]: string }>(this.getRawRequest({ + ...options, + + url: options.url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } +} diff --git a/shared/server-commands/videos/streaming-playlists.ts b/shared/server-commands/videos/streaming-playlists.ts new file mode 100644 index 000000000..0451c0efe --- /dev/null +++ b/shared/server-commands/videos/streaming-playlists.ts @@ -0,0 +1,77 @@ +import { expect } from 'chai' +import { basename } from 'path' +import { sha256 } from '@shared/core-utils/crypto' +import { removeFragmentedMP4Ext } from '@shared/core-utils' +import { HttpStatusCode, VideoStreamingPlaylist } from '@shared/models' +import { PeerTubeServer } from '../server' + +async function checkSegmentHash (options: { + server: PeerTubeServer + baseUrlPlaylist: string + baseUrlSegment: string + resolution: number + hlsPlaylist: VideoStreamingPlaylist +}) { + const { server, baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist } = options + const command = server.streamingPlaylists + + const file = hlsPlaylist.files.find(f => f.resolution.id === resolution) + const videoName = basename(file.fileUrl) + + const playlist = await command.get({ url: `${baseUrlPlaylist}/${removeFragmentedMP4Ext(videoName)}.m3u8` }) + + const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist) + + const length = parseInt(matches[1], 10) + const offset = parseInt(matches[2], 10) + const range = `${offset}-${offset + length - 1}` + + const segmentBody = await command.getSegment({ + url: `${baseUrlSegment}/${videoName}`, + expectedStatus: HttpStatusCode.PARTIAL_CONTENT_206, + range: `bytes=${range}` + }) + + const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url }) + expect(sha256(segmentBody)).to.equal(shaBody[videoName][range]) +} + +async function checkLiveSegmentHash (options: { + server: PeerTubeServer + baseUrlSegment: string + videoUUID: string + segmentName: string + hlsPlaylist: VideoStreamingPlaylist +}) { + const { server, baseUrlSegment, videoUUID, segmentName, hlsPlaylist } = options + const command = server.streamingPlaylists + + const segmentBody = await command.getSegment({ url: `${baseUrlSegment}/${videoUUID}/${segmentName}` }) + const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url }) + + expect(sha256(segmentBody)).to.equal(shaBody[segmentName]) +} + +async function checkResolutionsInMasterPlaylist (options: { + server: PeerTubeServer + playlistUrl: string + resolutions: number[] +}) { + const { server, playlistUrl, resolutions } = options + + const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl }) + + for (const resolution of resolutions) { + const reg = new RegExp( + '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + ',(FRAME-RATE=\\d+,)?CODECS="avc1.64001f,mp4a.40.2"' + ) + + expect(masterPlaylist).to.match(reg) + } +} + +export { + checkSegmentHash, + checkLiveSegmentHash, + checkResolutionsInMasterPlaylist +} diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts new file mode 100644 index 000000000..8ea828b40 --- /dev/null +++ b/shared/server-commands/videos/videos-command.ts @@ -0,0 +1,679 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { createReadStream, stat } from 'fs-extra' +import got, { Response as GotResponse } from 'got' +import { omit } from 'lodash' +import validator from 'validator' +import { buildUUID } from '@shared/core-utils/uuid' +import { pick } from '@shared/core-utils' +import { + HttpStatusCode, + ResultList, + UserVideoRateType, + Video, + VideoCreate, + VideoCreateResult, + VideoDetails, + VideoFileMetadata, + VideoPrivacy, + VideosCommonQuery, + VideoTranscodingCreate +} from '@shared/models' +import { buildAbsoluteFixturePath, wait } from '../miscs' +import { unwrapBody } from '../requests' +import { waitJobs } from '../server' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export type VideoEdit = Partial> & { + fixture?: string + thumbnailfile?: string + previewfile?: string +} + +export class VideosCommand extends AbstractCommand { + getCategories (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/categories' + + return this.getRequestBody<{ [id: number]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getLicences (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/licences' + + return this.getRequestBody<{ [id: number]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getLanguages (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/languages' + + return this.getRequestBody<{ [id: string]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getPrivacies (options: OverrideCommandOptions = {}) { + const path = '/api/v1/videos/privacies' + + return this.getRequestBody<{ [id in VideoPrivacy]: string }>({ + ...options, + path, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + getDescription (options: OverrideCommandOptions & { + descriptionPath: string + }) { + return this.getRequestBody<{ description: string }>({ + ...options, + path: options.descriptionPath, + + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getFileMetadata (options: OverrideCommandOptions & { + url: string + }) { + return unwrapBody(this.getRawRequest({ + ...options, + + url: options.url, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + // --------------------------------------------------------------------------- + + view (options: OverrideCommandOptions & { + id: number | string + xForwardedFor?: string + }) { + const { id, xForwardedFor } = options + const path = '/api/v1/videos/' + id + '/views' + + return this.postBodyRequest({ + ...options, + + path, + xForwardedFor, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + rate (options: OverrideCommandOptions & { + id: number | string + rating: UserVideoRateType + }) { + const { id, rating } = options + const path = '/api/v1/videos/' + id + '/rate' + + return this.putBodyRequest({ + ...options, + + path, + fields: { rating }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getWithToken (options: OverrideCommandOptions & { + id: number | string + }) { + return this.get({ + ...options, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + async getId (options: OverrideCommandOptions & { + uuid: number | string + }) { + const { uuid } = options + + if (validator.isUUID('' + uuid) === false) return uuid as number + + const { id } = await this.get({ ...options, id: uuid }) + + return id + } + + async listFiles (options: OverrideCommandOptions & { + id: number | string + }) { + const video = await this.get(options) + + const files = video.files || [] + const hlsFiles = video.streamingPlaylists[0]?.files || [] + + return files.concat(hlsFiles) + } + + // --------------------------------------------------------------------------- + + listMyVideos (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + isLive?: boolean + channelId?: number + } = {}) { + const path = '/api/v1/users/me/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + list (options: OverrideCommandOptions & VideosCommonQuery = {}) { + const path = '/api/v1/videos' + + const query = this.buildListQuery(options) + + return this.getRequestBody>({ + ...options, + + path, + query: { sort: 'name', ...query }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) { + return this.list({ + ...options, + + token: this.buildCommonRequestToken({ ...options, implicitToken: true }) + }) + } + + listByAccount (options: OverrideCommandOptions & VideosCommonQuery & { + handle: string + }) { + const { handle, search } = options + const path = '/api/v1/accounts/' + handle + '/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: { search, ...this.buildListQuery(options) }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listByChannel (options: OverrideCommandOptions & VideosCommonQuery & { + handle: string + }) { + const { handle } = options + const path = '/api/v1/video-channels/' + handle + '/videos' + + return this.getRequestBody>({ + ...options, + + path, + query: this.buildListQuery(options), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + async find (options: OverrideCommandOptions & { + name: string + }) { + const { data } = await this.list(options) + + return data.find(v => v.name === options.name) + } + + // --------------------------------------------------------------------------- + + update (options: OverrideCommandOptions & { + id: number | string + attributes?: VideoEdit + }) { + const { id, attributes = {} } = options + const path = '/api/v1/videos/' + id + + // Upload request + if (attributes.thumbnailfile || attributes.previewfile) { + const attaches: any = {} + if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile + if (attributes.previewfile) attaches.previewfile = attributes.previewfile + + return this.putUploadRequest({ + ...options, + + path, + fields: options.attributes, + attaches: { + thumbnailfile: attributes.thumbnailfile, + previewfile: attributes.previewfile + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + return this.putBodyRequest({ + ...options, + + path, + fields: options.attributes, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + id: number | string + }) { + const path = '/api/v1/videos/' + options.id + + return unwrapBody(this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + })) + } + + async removeAll () { + const { data } = await this.list() + + for (const v of data) { + await this.remove({ id: v.id }) + } + } + + // --------------------------------------------------------------------------- + + async upload (options: OverrideCommandOptions & { + attributes?: VideoEdit + mode?: 'legacy' | 'resumable' // default legacy + } = {}) { + const { mode = 'legacy' } = options + let defaultChannelId = 1 + + try { + const { videoChannels } = await this.server.users.getMyInfo({ token: options.token }) + defaultChannelId = videoChannels[0].id + } catch (e) { /* empty */ } + + // Override default attributes + const attributes = { + name: 'my super video', + category: 5, + licence: 4, + language: 'zh', + channelId: defaultChannelId, + nsfw: true, + waitTranscoding: false, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + fixture: 'video_short.webm', + + ...options.attributes + } + + const created = mode === 'legacy' + ? await this.buildLegacyUpload({ ...options, attributes }) + : await this.buildResumeUpload({ ...options, attributes }) + + // Wait torrent generation + const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 }) + if (expectedStatus === HttpStatusCode.OK_200) { + let video: VideoDetails + + do { + video = await this.getWithToken({ ...options, id: created.uuid }) + + await wait(50) + } while (!video.files[0].torrentUrl) + } + + return created + } + + async buildLegacyUpload (options: OverrideCommandOptions & { + attributes: VideoEdit + }): Promise { + const path = '/api/v1/videos/upload' + + return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({ + ...options, + + path, + fields: this.buildUploadFields(options.attributes), + attaches: this.buildUploadAttaches(options.attributes), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(body => body.video || body as any) + } + + async buildResumeUpload (options: OverrideCommandOptions & { + attributes: VideoEdit + }): Promise { + const { attributes, expectedStatus } = options + + let size = 0 + let videoFilePath: string + let mimetype = 'video/mp4' + + if (attributes.fixture) { + videoFilePath = buildAbsoluteFixturePath(attributes.fixture) + size = (await stat(videoFilePath)).size + + if (videoFilePath.endsWith('.mkv')) { + mimetype = 'video/x-matroska' + } else if (videoFilePath.endsWith('.webm')) { + mimetype = 'video/webm' + } + } + + // Do not check status automatically, we'll check it manually + const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype }) + const initStatus = initializeSessionRes.status + + if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) { + const locationHeader = initializeSessionRes.header['location'] + expect(locationHeader).to.not.be.undefined + + const pathUploadId = locationHeader.split('?')[1] + + const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size }) + + if (result.statusCode === HttpStatusCode.OK_200) { + await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId }) + } + + return result.body?.video || result.body as any + } + + const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200 + ? HttpStatusCode.CREATED_201 + : expectedStatus + + expect(initStatus).to.equal(expectedInitStatus) + + return initializeSessionRes.body.video || initializeSessionRes.body + } + + async prepareResumableUpload (options: OverrideCommandOptions & { + attributes: VideoEdit + size: number + mimetype: string + + originalName?: string + lastModified?: number + }) { + const { attributes, originalName, lastModified, size, mimetype } = options + + const path = '/api/v1/videos/upload-resumable' + + return this.postUploadRequest({ + ...options, + + path, + headers: { + 'X-Upload-Content-Type': mimetype, + 'X-Upload-Content-Length': size.toString() + }, + fields: { + filename: attributes.fixture, + originalName, + lastModified, + + ...this.buildUploadFields(options.attributes) + }, + + // Fixture will be sent later + attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')), + implicitToken: true, + + defaultExpectedStatus: null + }) + } + + sendResumableChunks (options: OverrideCommandOptions & { + pathUploadId: string + videoFilePath: string + size: number + contentLength?: number + contentRangeBuilder?: (start: number, chunk: any) => string + }) { + const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options + + const path = '/api/v1/videos/upload-resumable' + let start = 0 + + const token = this.buildCommonRequestToken({ ...options, implicitToken: true }) + const url = this.server.url + + const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 }) + return new Promise>((resolve, reject) => { + readable.on('data', async function onData (chunk) { + readable.pause() + + const headers = { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/octet-stream', + 'Content-Range': contentRangeBuilder + ? contentRangeBuilder(start, chunk) + : `bytes ${start}-${start + chunk.length - 1}/${size}`, + 'Content-Length': contentLength ? contentLength + '' : chunk.length + '' + } + + const res = await got<{ video: VideoCreateResult }>({ + url, + method: 'put', + headers, + path: path + '?' + pathUploadId, + body: chunk, + responseType: 'json', + throwHttpErrors: false + }) + + start += chunk.length + + if (res.statusCode === expectedStatus) { + return resolve(res) + } + + if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) { + readable.off('data', onData) + return reject(new Error('Incorrect transient behaviour sending intermediary chunks')) + } + + readable.resume() + }) + }) + } + + endResumableUpload (options: OverrideCommandOptions & { + pathUploadId: string + }) { + return this.deleteRequest({ + ...options, + + path: '/api/v1/videos/upload-resumable', + rawQuery: options.pathUploadId, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + quickUpload (options: OverrideCommandOptions & { + name: string + nsfw?: boolean + privacy?: VideoPrivacy + fixture?: string + }) { + const attributes: VideoEdit = { name: options.name } + if (options.nsfw) attributes.nsfw = options.nsfw + if (options.privacy) attributes.privacy = options.privacy + if (options.fixture) attributes.fixture = options.fixture + + return this.upload({ ...options, attributes }) + } + + async randomUpload (options: OverrideCommandOptions & { + wait?: boolean // default true + additionalParams?: VideoEdit & { prefixName?: string } + } = {}) { + const { wait = true, additionalParams } = options + const prefixName = additionalParams?.prefixName || '' + const name = prefixName + buildUUID() + + const attributes = { name, ...additionalParams } + + const result = await this.upload({ ...options, attributes }) + + if (wait) await waitJobs([ this.server ]) + + return { ...result, name } + } + + // --------------------------------------------------------------------------- + + removeHLSFiles (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/hls' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeWebTorrentFiles (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/webtorrent' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + runTranscoding (options: OverrideCommandOptions & { + videoId: number | string + transcodingType: 'hls' | 'webtorrent' + }) { + const path = '/api/v1/videos/' + options.videoId + '/transcoding' + + const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ]) + + return this.postBodyRequest({ + ...options, + + path, + fields, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + private buildListQuery (options: VideosCommonQuery) { + return pick(options, [ + 'start', + 'count', + 'sort', + 'nsfw', + 'isLive', + 'categoryOneOf', + 'licenceOneOf', + 'languageOneOf', + 'tagsOneOf', + 'tagsAllOf', + 'isLocal', + 'include', + 'skipCount' + ]) + } + + private buildUploadFields (attributes: VideoEdit) { + return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ]) + } + + private buildUploadAttaches (attributes: VideoEdit) { + const attaches: { [ name: string ]: string } = {} + + for (const key of [ 'thumbnailfile', 'previewfile' ]) { + if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key]) + } + + if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture) + + return attaches + } +} diff --git a/shared/server-commands/videos/videos.ts b/shared/server-commands/videos/videos.ts new file mode 100644 index 000000000..2c3464aa8 --- /dev/null +++ b/shared/server-commands/videos/videos.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { expect } from 'chai' +import { pathExists, readdir } from 'fs-extra' +import { basename, join } from 'path' +import { HttpStatusCode, VideoCaption, VideoDetails } from '@shared/models' +import { waitJobs } from '../server' +import { PeerTubeServer } from '../server/server' +import { VideoEdit } from './videos-command' + +async function checkVideoFilesWereRemoved (options: { + server: PeerTubeServer + video: VideoDetails + captions?: VideoCaption[] + onlyVideoFiles?: boolean // default false +}) { + const { video, server, captions = [], onlyVideoFiles = false } = options + + const webtorrentFiles = video.files || [] + const hlsFiles = video.streamingPlaylists[0]?.files || [] + + const thumbnailName = basename(video.thumbnailPath) + const previewName = basename(video.previewPath) + + const torrentNames = webtorrentFiles.concat(hlsFiles).map(f => basename(f.torrentUrl)) + + const captionNames = captions.map(c => basename(c.captionPath)) + + const webtorrentFilenames = webtorrentFiles.map(f => basename(f.fileUrl)) + const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl)) + + let directories: { [ directory: string ]: string[] } = { + videos: webtorrentFilenames, + redundancy: webtorrentFilenames, + [join('playlists', 'hls')]: hlsFilenames, + [join('redundancy', 'hls')]: hlsFilenames + } + + if (onlyVideoFiles !== true) { + directories = { + ...directories, + + thumbnails: [ thumbnailName ], + previews: [ previewName ], + torrents: torrentNames, + captions: captionNames + } + } + + for (const directory of Object.keys(directories)) { + const directoryPath = server.servers.buildDirectory(directory) + + const directoryExists = await pathExists(directoryPath) + if (directoryExists === false) continue + + const existingFiles = await readdir(directoryPath) + for (const existingFile of existingFiles) { + for (const shouldNotExist of directories[directory]) { + expect(existingFile, `File ${existingFile} should not exist in ${directoryPath}`).to.not.contain(shouldNotExist) + } + } + } +} + +async function saveVideoInServers (servers: PeerTubeServer[], uuid: string) { + for (const server of servers) { + server.store.videoDetails = await server.videos.get({ id: uuid }) + } +} + +function checkUploadVideoParam ( + server: PeerTubeServer, + token: string, + attributes: Partial, + expectedStatus = HttpStatusCode.OK_200, + mode: 'legacy' | 'resumable' = 'legacy' +) { + return mode === 'legacy' + ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus }) + : server.videos.buildResumeUpload({ token, attributes, expectedStatus }) +} + +// serverNumber starts from 1 +async function uploadRandomVideoOnServers ( + servers: PeerTubeServer[], + serverNumber: number, + additionalParams?: VideoEdit & { prefixName?: string } +) { + const server = servers.find(s => s.serverNumber === serverNumber) + const res = await server.videos.randomUpload({ wait: false, additionalParams }) + + await waitJobs(servers) + + return res +} + +// --------------------------------------------------------------------------- + +export { + checkUploadVideoParam, + uploadRandomVideoOnServers, + checkVideoFilesWereRemoved, + saveVideoInServers +} -- cgit v1.2.3