aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared/server-commands
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2021-12-17 09:29:23 +0100
committerChocobozzz <me@florianbigard.com>2021-12-17 09:29:23 +0100
commitbf54587a3e2ad9c2c186828f2a5682b91ee2cc00 (patch)
tree54b40aaf01bae210632473285c3c7571d51e4f89 /shared/server-commands
parent6b5f72beda96d8b7e4d6329c4001827334de27dd (diff)
downloadPeerTube-bf54587a3e2ad9c2c186828f2a5682b91ee2cc00.tar.gz
PeerTube-bf54587a3e2ad9c2c186828f2a5682b91ee2cc00.tar.zst
PeerTube-bf54587a3e2ad9c2c186828f2a5682b91ee2cc00.zip
shared/ typescript types dir server-commands
Diffstat (limited to 'shared/server-commands')
-rw-r--r--shared/server-commands/bulk/bulk-command.ts20
-rw-r--r--shared/server-commands/bulk/index.ts1
-rw-r--r--shared/server-commands/cli/cli-command.ts27
-rw-r--r--shared/server-commands/cli/index.ts1
-rw-r--r--shared/server-commands/custom-pages/custom-pages-command.ts33
-rw-r--r--shared/server-commands/custom-pages/index.ts1
-rw-r--r--shared/server-commands/feeds/feeds-command.ts44
-rw-r--r--shared/server-commands/feeds/index.ts1
-rw-r--r--shared/server-commands/index.ts15
-rw-r--r--shared/server-commands/logs/index.ts1
-rw-r--r--shared/server-commands/logs/logs-command.ts44
-rw-r--r--shared/server-commands/miscs/checks.ts58
-rw-r--r--shared/server-commands/miscs/generate.ts75
-rw-r--r--shared/server-commands/miscs/index.ts5
-rw-r--r--shared/server-commands/miscs/sql-command.ts141
-rw-r--r--shared/server-commands/miscs/tests.ts101
-rw-r--r--shared/server-commands/miscs/webtorrent.ts46
-rw-r--r--shared/server-commands/mock-servers/index.ts5
-rw-r--r--shared/server-commands/mock-servers/mock-429.ts33
-rw-r--r--shared/server-commands/mock-servers/mock-email.ts63
-rw-r--r--shared/server-commands/mock-servers/mock-instances-index.ts46
-rw-r--r--shared/server-commands/mock-servers/mock-joinpeertube-versions.ts34
-rw-r--r--shared/server-commands/mock-servers/mock-object-storage.ts41
-rw-r--r--shared/server-commands/mock-servers/mock-plugin-blocklist.ts36
-rw-r--r--shared/server-commands/mock-servers/mock-proxy.ts25
-rw-r--r--shared/server-commands/mock-servers/utils.ts33
-rw-r--r--shared/server-commands/moderation/abuses-command.ts228
-rw-r--r--shared/server-commands/moderation/index.ts1
-rw-r--r--shared/server-commands/overviews/index.ts1
-rw-r--r--shared/server-commands/overviews/overviews-command.ts23
-rw-r--r--shared/server-commands/requests/check-api-params.ts48
-rw-r--r--shared/server-commands/requests/index.ts3
-rw-r--r--shared/server-commands/requests/requests.ts208
-rw-r--r--shared/server-commands/search/index.ts1
-rw-r--r--shared/server-commands/search/search-command.ts98
-rw-r--r--shared/server-commands/server/config-command.ts353
-rw-r--r--shared/server-commands/server/contact-form-command.ts31
-rw-r--r--shared/server-commands/server/debug-command.ts33
-rw-r--r--shared/server-commands/server/directories.ts34
-rw-r--r--shared/server-commands/server/follows-command.ts139
-rw-r--r--shared/server-commands/server/follows.ts20
-rw-r--r--shared/server-commands/server/index.ts17
-rw-r--r--shared/server-commands/server/jobs-command.ts61
-rw-r--r--shared/server-commands/server/jobs.ts84
-rw-r--r--shared/server-commands/server/object-storage-command.ts77
-rw-r--r--shared/server-commands/server/plugins-command.ts257
-rw-r--r--shared/server-commands/server/plugins.ts18
-rw-r--r--shared/server-commands/server/redundancy-command.ts80
-rw-r--r--shared/server-commands/server/server.ts392
-rw-r--r--shared/server-commands/server/servers-command.ts92
-rw-r--r--shared/server-commands/server/servers.ts49
-rw-r--r--shared/server-commands/server/stats-command.ts25
-rw-r--r--shared/server-commands/server/tracker.ts27
-rw-r--r--shared/server-commands/shared/abstract-command.ts211
-rw-r--r--shared/server-commands/shared/index.ts1
-rw-r--r--shared/server-commands/socket/index.ts1
-rw-r--r--shared/server-commands/socket/socket-io-command.ts15
-rw-r--r--shared/server-commands/users/accounts-command.ts78
-rw-r--r--shared/server-commands/users/actors.ts73
-rw-r--r--shared/server-commands/users/blocklist-command.ts162
-rw-r--r--shared/server-commands/users/index.ts9
-rw-r--r--shared/server-commands/users/login-command.ts132
-rw-r--r--shared/server-commands/users/login.ts19
-rw-r--r--shared/server-commands/users/notifications-command.ts86
-rw-r--r--shared/server-commands/users/notifications.ts795
-rw-r--r--shared/server-commands/users/subscriptions-command.ts99
-rw-r--r--shared/server-commands/users/users-command.ts416
-rw-r--r--shared/server-commands/videos/blacklist-command.ts76
-rw-r--r--shared/server-commands/videos/captions-command.ts65
-rw-r--r--shared/server-commands/videos/captions.ts21
-rw-r--r--shared/server-commands/videos/change-ownership-command.ts68
-rw-r--r--shared/server-commands/videos/channels-command.ts178
-rw-r--r--shared/server-commands/videos/channels.ts18
-rw-r--r--shared/server-commands/videos/comments-command.ts152
-rw-r--r--shared/server-commands/videos/history-command.ts58
-rw-r--r--shared/server-commands/videos/imports-command.ts47
-rw-r--r--shared/server-commands/videos/index.ts19
-rw-r--r--shared/server-commands/videos/live-command.ts155
-rw-r--r--shared/server-commands/videos/live.ts137
-rw-r--r--shared/server-commands/videos/playlists-command.ts280
-rw-r--r--shared/server-commands/videos/playlists.ts25
-rw-r--r--shared/server-commands/videos/services-command.ts29
-rw-r--r--shared/server-commands/videos/streaming-playlists-command.ts44
-rw-r--r--shared/server-commands/videos/streaming-playlists.ts77
-rw-r--r--shared/server-commands/videos/videos-command.ts679
-rw-r--r--shared/server-commands/videos/videos.ts104
86 files changed, 7659 insertions, 0 deletions
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 @@
1import { BulkRemoveCommentsOfBody, HttpStatusCode } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class BulkCommand extends AbstractCommand {
5
6 removeCommentsOf (options: OverrideCommandOptions & {
7 attributes: BulkRemoveCommentsOfBody
8 }) {
9 const { attributes } = options
10
11 return this.postBodyRequest({
12 ...options,
13
14 path: '/api/v1/bulk/remove-comments-of',
15 fields: attributes,
16 implicitToken: true,
17 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
18 })
19 }
20}
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 @@
1import { exec } from 'child_process'
2import { AbstractCommand } from '../shared'
3
4export class CLICommand extends AbstractCommand {
5
6 static exec (command: string) {
7 return new Promise<string>((res, rej) => {
8 exec(command, (err, stdout, _stderr) => {
9 if (err) return rej(err)
10
11 return res(stdout)
12 })
13 })
14 }
15
16 getEnv () {
17 return `NODE_ENV=test NODE_APP_INSTANCE=${this.server.internalServerNumber}`
18 }
19
20 async execWithEnv (command: string, configOverride?: any) {
21 const prefix = configOverride
22 ? `NODE_CONFIG='${JSON.stringify(configOverride)}'`
23 : ''
24
25 return CLICommand.exec(`${prefix} ${this.getEnv()} ${command}`)
26 }
27}
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 @@
1import { CustomPage, HttpStatusCode } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class CustomPagesCommand extends AbstractCommand {
5
6 getInstanceHomepage (options: OverrideCommandOptions = {}) {
7 const path = '/api/v1/custom-pages/homepage/instance'
8
9 return this.getRequestBody<CustomPage>({
10 ...options,
11
12 path,
13 implicitToken: false,
14 defaultExpectedStatus: HttpStatusCode.OK_200
15 })
16 }
17
18 updateInstanceHomepage (options: OverrideCommandOptions & {
19 content: string
20 }) {
21 const { content } = options
22 const path = '/api/v1/custom-pages/homepage/instance'
23
24 return this.putBodyRequest({
25 ...options,
26
27 path,
28 fields: { content },
29 implicitToken: true,
30 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
31 })
32 }
33}
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 @@
1
2import { HttpStatusCode } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5type FeedType = 'videos' | 'video-comments' | 'subscriptions'
6
7export class FeedCommand extends AbstractCommand {
8
9 getXML (options: OverrideCommandOptions & {
10 feed: FeedType
11 format?: string
12 }) {
13 const { feed, format } = options
14 const path = '/feeds/' + feed + '.xml'
15
16 return this.getRequestText({
17 ...options,
18
19 path,
20 query: format ? { format } : undefined,
21 accept: 'application/xml',
22 implicitToken: false,
23 defaultExpectedStatus: HttpStatusCode.OK_200
24 })
25 }
26
27 getJSON (options: OverrideCommandOptions & {
28 feed: FeedType
29 query?: { [ id: string ]: any }
30 }) {
31 const { feed, query } = options
32 const path = '/feeds/' + feed + '.json'
33
34 return this.getRequestText({
35 ...options,
36
37 path,
38 query,
39 accept: 'application/json',
40 implicitToken: false,
41 defaultExpectedStatus: HttpStatusCode.OK_200
42 })
43 }
44}
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 @@
1export * from './bulk'
2export * from './cli'
3export * from './custom-pages'
4export * from './feeds'
5export * from './logs'
6export * from './miscs'
7export * from './mock-servers'
8export * from './moderation'
9export * from './overviews'
10export * from './requests'
11export * from './search'
12export * from './server'
13export * from './socket'
14export * from './users'
15export * 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 @@
1import { HttpStatusCode } from '@shared/models'
2import { LogLevel } from '../../models/server/log-level.type'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class LogsCommand extends AbstractCommand {
6
7 getLogs (options: OverrideCommandOptions & {
8 startDate: Date
9 endDate?: Date
10 level?: LogLevel
11 tagsOneOf?: string[]
12 }) {
13 const { startDate, endDate, tagsOneOf, level } = options
14 const path = '/api/v1/server/logs'
15
16 return this.getRequestBody<any[]>({
17 ...options,
18
19 path,
20 query: { startDate, endDate, level, tagsOneOf },
21 implicitToken: true,
22 defaultExpectedStatus: HttpStatusCode.OK_200
23 })
24 }
25
26 getAuditLogs (options: OverrideCommandOptions & {
27 startDate: Date
28 endDate?: Date
29 }) {
30 const { startDate, endDate } = options
31
32 const path = '/api/v1/server/audit-logs'
33
34 return this.getRequestBody({
35 ...options,
36
37 path,
38 query: { startDate, endDate },
39 implicitToken: true,
40 defaultExpectedStatus: HttpStatusCode.OK_200
41 })
42 }
43
44}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { pathExists, readFile } from 'fs-extra'
5import { join } from 'path'
6import { root } from '@shared/core-utils'
7import { HttpStatusCode } from '@shared/models'
8import { makeGetRequest } from '../requests'
9import { PeerTubeServer } from '../server'
10
11// Default interval -> 5 minutes
12function dateIsValid (dateString: string, interval = 300000) {
13 const dateToCheck = new Date(dateString)
14 const now = new Date()
15
16 return Math.abs(now.getTime() - dateToCheck.getTime()) <= interval
17}
18
19function expectStartWith (str: string, start: string) {
20 expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.true
21}
22
23async function expectLogDoesNotContain (server: PeerTubeServer, str: string) {
24 const content = await server.servers.getLogContent()
25
26 expect(content.toString()).to.not.contain(str)
27}
28
29async function testImage (url: string, imageName: string, imagePath: string, extension = '.jpg') {
30 const res = await makeGetRequest({
31 url,
32 path: imagePath,
33 expectedStatus: HttpStatusCode.OK_200
34 })
35
36 const body = res.body
37
38 const data = await readFile(join(root(), 'server', 'tests', 'fixtures', imageName + extension))
39 const minLength = body.length - ((30 * body.length) / 100)
40 const maxLength = body.length + ((30 * body.length) / 100)
41
42 expect(data.length).to.be.above(minLength, 'the generated image is way smaller than the recorded fixture')
43 expect(data.length).to.be.below(maxLength, 'the generated image is way larger than the recorded fixture')
44}
45
46async function testFileExistsOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) {
47 const base = server.servers.buildDirectory(directory)
48
49 expect(await pathExists(join(base, filePath))).to.equal(exist)
50}
51
52export {
53 dateIsValid,
54 testImage,
55 expectLogDoesNotContain,
56 testFileExistsOrNot,
57 expectStartWith
58}
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 @@
1import { expect } from 'chai'
2import ffmpeg from 'fluent-ffmpeg'
3import { ensureDir, pathExists } from 'fs-extra'
4import { dirname } from 'path'
5import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils/ffprobe'
6import { getMaxBitrate } from '@shared/core-utils'
7import { buildAbsoluteFixturePath } from './tests'
8
9async function ensureHasTooBigBitrate (fixturePath: string) {
10 const bitrate = await getVideoFileBitrate(fixturePath)
11 const dataResolution = await getVideoFileResolution(fixturePath)
12 const fps = await getVideoFileFPS(fixturePath)
13
14 const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
15 expect(bitrate).to.be.above(maxBitrate)
16}
17
18async function generateHighBitrateVideo () {
19 const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4', true)
20
21 await ensureDir(dirname(tempFixturePath))
22
23 const exists = await pathExists(tempFixturePath)
24 if (!exists) {
25 console.log('Generating high bitrate video.')
26
27 // Generate a random, high bitrate video on the fly, so we don't have to include
28 // a large file in the repo. The video needs to have a certain minimum length so
29 // that FFmpeg properly applies bitrate limits.
30 // https://stackoverflow.com/a/15795112
31 return new Promise<string>((res, rej) => {
32 ffmpeg()
33 .outputOptions([ '-f rawvideo', '-video_size 1920x1080', '-i /dev/urandom' ])
34 .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ])
35 .outputOptions([ '-maxrate 10M', '-bufsize 10M' ])
36 .output(tempFixturePath)
37 .on('error', rej)
38 .on('end', () => res(tempFixturePath))
39 .run()
40 })
41 }
42
43 await ensureHasTooBigBitrate(tempFixturePath)
44
45 return tempFixturePath
46}
47
48async function generateVideoWithFramerate (fps = 60) {
49 const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true)
50
51 await ensureDir(dirname(tempFixturePath))
52
53 const exists = await pathExists(tempFixturePath)
54 if (!exists) {
55 console.log('Generating video with framerate %d.', fps)
56
57 return new Promise<string>((res, rej) => {
58 ffmpeg()
59 .outputOptions([ '-f rawvideo', '-video_size 1280x720', '-i /dev/urandom' ])
60 .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ])
61 .outputOptions([ `-r ${fps}` ])
62 .output(tempFixturePath)
63 .on('error', rej)
64 .on('end', () => res(tempFixturePath))
65 .run()
66 })
67 }
68
69 return tempFixturePath
70}
71
72export {
73 generateHighBitrateVideo,
74 generateVideoWithFramerate
75}
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 @@
1export * from './checks'
2export * from './generate'
3export * from './sql-command'
4export * from './tests'
5export * 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 @@
1import { QueryTypes, Sequelize } from 'sequelize'
2import { AbstractCommand } from '../shared/abstract-command'
3
4export class SQLCommand extends AbstractCommand {
5 private sequelize: Sequelize
6
7 deleteAll (table: string) {
8 const seq = this.getSequelize()
9
10 const options = { type: QueryTypes.DELETE }
11
12 return seq.query(`DELETE FROM "${table}"`, options)
13 }
14
15 async getCount (table: string) {
16 const seq = this.getSequelize()
17
18 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
19
20 const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
21 if (total === null) return 0
22
23 return parseInt(total, 10)
24 }
25
26 setActorField (to: string, field: string, value: string) {
27 const seq = this.getSequelize()
28
29 const options = { type: QueryTypes.UPDATE }
30
31 return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
32 }
33
34 setVideoField (uuid: string, field: string, value: string) {
35 const seq = this.getSequelize()
36
37 const options = { type: QueryTypes.UPDATE }
38
39 return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
40 }
41
42 setPlaylistField (uuid: string, field: string, value: string) {
43 const seq = this.getSequelize()
44
45 const options = { type: QueryTypes.UPDATE }
46
47 return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
48 }
49
50 async countVideoViewsOf (uuid: string) {
51 const seq = this.getSequelize()
52
53 const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
54 `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'`
55
56 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
57 const [ { total } ] = await seq.query<{ total: number }>(query, options)
58
59 if (!total) return 0
60
61 return parseInt(total + '', 10)
62 }
63
64 getActorImage (filename: string) {
65 return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`)
66 .then(rows => rows[0])
67 }
68
69 selectQuery (query: string) {
70 const seq = this.getSequelize()
71 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
72
73 return seq.query<any>(query, options)
74 }
75
76 updateQuery (query: string) {
77 const seq = this.getSequelize()
78 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE }
79
80 return seq.query(query, options)
81 }
82
83 setPluginField (pluginName: string, field: string, value: string) {
84 const seq = this.getSequelize()
85
86 const options = { type: QueryTypes.UPDATE }
87
88 return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
89 }
90
91 setPluginVersion (pluginName: string, newVersion: string) {
92 return this.setPluginField(pluginName, 'version', newVersion)
93 }
94
95 setPluginLatestVersion (pluginName: string, newVersion: string) {
96 return this.setPluginField(pluginName, 'latestVersion', newVersion)
97 }
98
99 setActorFollowScores (newScore: number) {
100 const seq = this.getSequelize()
101
102 const options = { type: QueryTypes.UPDATE }
103
104 return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
105 }
106
107 setTokenField (accessToken: string, field: string, value: string) {
108 const seq = this.getSequelize()
109
110 const options = { type: QueryTypes.UPDATE }
111
112 return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
113 }
114
115 async cleanup () {
116 if (!this.sequelize) return
117
118 await this.sequelize.close()
119 this.sequelize = undefined
120 }
121
122 private getSequelize () {
123 if (this.sequelize) return this.sequelize
124
125 const dbname = 'peertube_test' + this.server.internalServerNumber
126 const username = 'peertube'
127 const password = 'peertube'
128 const host = 'localhost'
129 const port = 5432
130
131 this.sequelize = new Sequelize(dbname, username, password, {
132 dialect: 'postgres',
133 host,
134 port,
135 logging: false
136 })
137
138 return this.sequelize
139 }
140
141}
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 @@
1import { stat } from 'fs-extra'
2import { basename, isAbsolute, join, resolve } from 'path'
3
4const FIXTURE_URLS = {
5 peertube_long: 'https://peertube2.cpy.re/videos/watch/122d093a-1ede-43bd-bd34-59d2931ffc5e',
6 peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd',
7
8 youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM',
9
10 /**
11 * The video is used to check format-selection correctness wrt. HDR,
12 * which brings its own set of oddities outside of a MediaSource.
13 *
14 * The video needs to have the following format_ids:
15 * (which you can check by using `youtube-dl <url> -F`):
16 * - (webm vp9)
17 * - (mp4 avc1)
18 * - (webm vp9.2 HDR)
19 */
20 youtubeHDR: 'https://www.youtube.com/watch?v=RQgnBB9z_N4',
21
22 // eslint-disable-next-line max-len
23 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',
24
25 badVideo: 'https://download.cpy.re/peertube/bad_video.mp4',
26 goodVideo: 'https://download.cpy.re/peertube/good_video.mp4',
27 goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4',
28
29 file4K: 'https://download.cpy.re/peertube/4k_file.txt'
30}
31
32function parallelTests () {
33 return process.env.MOCHA_PARALLEL === 'true'
34}
35
36function isGithubCI () {
37 return !!process.env.GITHUB_WORKSPACE
38}
39
40function areHttpImportTestsDisabled () {
41 const disabled = process.env.DISABLE_HTTP_IMPORT_TESTS === 'true'
42
43 if (disabled) console.log('DISABLE_HTTP_IMPORT_TESTS env set to "true" so import tests are disabled')
44
45 return disabled
46}
47
48function areObjectStorageTestsDisabled () {
49 const disabled = process.env.ENABLE_OBJECT_STORAGE_TESTS !== 'true'
50
51 if (disabled) console.log('ENABLE_OBJECT_STORAGE_TESTS env is not set to "true" so object storage tests are disabled')
52
53 return disabled
54}
55
56function buildAbsoluteFixturePath (path: string, customCIPath = false) {
57 if (isAbsolute(path)) return path
58
59 if (customCIPath && process.env.GITHUB_WORKSPACE) {
60 return join(process.env.GITHUB_WORKSPACE, 'fixtures', path)
61 }
62
63 return join(root(), 'server', 'tests', 'fixtures', path)
64}
65
66function root () {
67 // We are in /miscs
68 let root = join(__dirname, '..', '..', '..')
69
70 if (basename(root) === 'dist') root = resolve(root, '..')
71
72 return root
73}
74
75function wait (milliseconds: number) {
76 return new Promise(resolve => setTimeout(resolve, milliseconds))
77}
78
79async function getFileSize (path: string) {
80 const stats = await stat(path)
81
82 return stats.size
83}
84
85function buildRequestStub (): any {
86 return { }
87}
88
89export {
90 FIXTURE_URLS,
91
92 parallelTests,
93 isGithubCI,
94 areHttpImportTestsDisabled,
95 buildAbsoluteFixturePath,
96 getFileSize,
97 buildRequestStub,
98 areObjectStorageTestsDisabled,
99 wait,
100 root
101}
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 @@
1import { readFile } from 'fs-extra'
2import parseTorrent from 'parse-torrent'
3import { basename, join } from 'path'
4import * as WebTorrent from 'webtorrent'
5import { VideoFile } from '@shared/models'
6import { PeerTubeServer } from '../server'
7
8let webtorrent: WebTorrent.Instance
9
10function webtorrentAdd (torrentId: string, refreshWebTorrent = false) {
11 const WebTorrent = require('webtorrent')
12
13 if (webtorrent && refreshWebTorrent) webtorrent.destroy()
14 if (!webtorrent || refreshWebTorrent) webtorrent = new WebTorrent()
15
16 webtorrent.on('error', err => console.error('Error in webtorrent', err))
17
18 return new Promise<WebTorrent.Torrent>(res => {
19 const torrent = webtorrent.add(torrentId, res)
20
21 torrent.on('error', err => console.error('Error in webtorrent torrent', err))
22 torrent.on('warning', warn => {
23 const msg = typeof warn === 'string'
24 ? warn
25 : warn.message
26
27 if (msg.includes('Unsupported')) return
28
29 console.error('Warning in webtorrent torrent', warn)
30 })
31 })
32}
33
34async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile) {
35 const torrentName = basename(file.torrentUrl)
36 const torrentPath = server.servers.buildDirectory(join('torrents', torrentName))
37
38 const data = await readFile(torrentPath)
39
40 return parseTorrent(data)
41}
42
43export {
44 webtorrentAdd,
45 parseTorrentVideo
46}
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 @@
1export * from './mock-email'
2export * from './mock-instances-index'
3export * from './mock-joinpeertube-versions'
4export * from './mock-plugin-blocklist'
5export * 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 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './utils'
4
5export class Mock429 {
6 private server: Server
7 private responseSent = false
8
9 async initialize () {
10 const app = express()
11
12 app.get('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
13
14 if (!this.responseSent) {
15 this.responseSent = true
16
17 // Retry after 5 seconds
18 res.header('retry-after', '2')
19 return res.sendStatus(429)
20 }
21
22 return res.sendStatus(200)
23 })
24
25 this.server = await randomListen(app)
26
27 return getPort(this.server)
28 }
29
30 terminate () {
31 return terminateServer(this.server)
32 }
33}
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 @@
1import { ChildProcess } from 'child_process'
2import MailDev from '@peertube/maildev'
3import { randomInt } from '@shared/core-utils'
4import { parallelTests } from '../miscs'
5
6class MockSmtpServer {
7
8 private static instance: MockSmtpServer
9 private started = false
10 private emailChildProcess: ChildProcess
11 private emails: object[]
12
13 private constructor () { }
14
15 collectEmails (emailsCollection: object[]) {
16 return new Promise<number>((res, rej) => {
17 const port = parallelTests() ? randomInt(1000, 2000) : 1025
18 this.emails = emailsCollection
19
20 if (this.started) {
21 return res(undefined)
22 }
23
24 const maildev = new MailDev({
25 ip: '127.0.0.1',
26 smtp: port,
27 disableWeb: true,
28 silent: true
29 })
30
31 maildev.on('new', email => {
32 this.emails.push(email)
33 })
34
35 maildev.listen(err => {
36 if (err) return rej(err)
37
38 this.started = true
39
40 return res(port)
41 })
42 })
43 }
44
45 kill () {
46 if (!this.emailChildProcess) return
47
48 process.kill(this.emailChildProcess.pid)
49
50 this.emailChildProcess = null
51 MockSmtpServer.instance = null
52 }
53
54 static get Instance () {
55 return this.instance || (this.instance = new this())
56 }
57}
58
59// ---------------------------------------------------------------------------
60
61export {
62 MockSmtpServer
63}
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 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './utils'
4
5export class MockInstancesIndex {
6 private server: Server
7
8 private readonly indexInstances: { host: string, createdAt: string }[] = []
9
10 async initialize () {
11 const app = express()
12
13 app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url)
15
16 return next()
17 })
18
19 app.get('/api/v1/instances/hosts', (req: express.Request, res: express.Response) => {
20 const since = req.query.since
21
22 const filtered = this.indexInstances.filter(i => {
23 if (!since) return true
24
25 return i.createdAt > since
26 })
27
28 return res.json({
29 total: filtered.length,
30 data: filtered
31 })
32 })
33
34 this.server = await randomListen(app)
35
36 return getPort(this.server)
37 }
38
39 addInstance (host: string) {
40 this.indexInstances.push({ host, createdAt: new Date().toISOString() })
41 }
42
43 terminate () {
44 return terminateServer(this.server)
45 }
46}
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 @@
1import express from 'express'
2import { Server } from 'http'
3import { getPort, randomListen } from './utils'
4
5export class MockJoinPeerTubeVersions {
6 private server: Server
7 private latestVersion: string
8
9 async initialize () {
10 const app = express()
11
12 app.use('/', (req: express.Request, res: express.Response, next: express.NextFunction) => {
13 if (process.env.DEBUG) console.log('Receiving request on mocked server %s.', req.url)
14
15 return next()
16 })
17
18 app.get('/versions.json', (req: express.Request, res: express.Response) => {
19 return res.json({
20 peertube: {
21 latestVersion: this.latestVersion
22 }
23 })
24 })
25
26 this.server = await randomListen(app)
27
28 return getPort(this.server)
29 }
30
31 setLatestVersion (latestVersion: string) {
32 this.latestVersion = latestVersion
33 }
34}
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 @@
1import express from 'express'
2import got, { RequestError } from 'got'
3import { Server } from 'http'
4import { pipeline } from 'stream'
5import { ObjectStorageCommand } from '../server'
6import { getPort, randomListen, terminateServer } from './utils'
7
8export class MockObjectStorage {
9 private server: Server
10
11 async initialize () {
12 const app = express()
13
14 app.get('/:bucketName/:path(*)', (req: express.Request, res: express.Response, next: express.NextFunction) => {
15 const url = `http://${req.params.bucketName}.${ObjectStorageCommand.getEndpointHost()}/${req.params.path}`
16
17 if (process.env.DEBUG) {
18 console.log('Receiving request on mocked server %s.', req.url)
19 console.log('Proxifying request to %s', url)
20 }
21
22 return pipeline(
23 got.stream(url, { throwHttpErrors: false }),
24 res,
25 (err: RequestError) => {
26 if (!err) return
27
28 console.error('Pipeline failed.', err)
29 }
30 )
31 })
32
33 this.server = await randomListen(app)
34
35 return getPort(this.server)
36 }
37
38 terminate () {
39 return terminateServer(this.server)
40 }
41}
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 @@
1import express, { Request, Response } from 'express'
2import { Server } from 'http'
3import { getPort, randomListen, terminateServer } from './utils'
4
5type BlocklistResponse = {
6 data: {
7 value: string
8 action?: 'add' | 'remove'
9 updatedAt?: string
10 }[]
11}
12
13export class MockBlocklist {
14 private body: BlocklistResponse
15 private server: Server
16
17 async initialize () {
18 const app = express()
19
20 app.get('/blocklist', (req: Request, res: Response) => {
21 return res.json(this.body)
22 })
23
24 this.server = await randomListen(app)
25
26 return getPort(this.server)
27 }
28
29 replace (body: BlocklistResponse) {
30 this.body = body
31 }
32
33 terminate () {
34 return terminateServer(this.server)
35 }
36}
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 @@
1
2import { createServer, Server } from 'http'
3import proxy from 'proxy'
4import { getPort, terminateServer } from './utils'
5
6class MockProxy {
7 private server: Server
8
9 initialize () {
10 return new Promise<number>(res => {
11 this.server = proxy(createServer())
12 this.server.listen(0, () => res(getPort(this.server)))
13 })
14 }
15
16 terminate () {
17 return terminateServer(this.server)
18 }
19}
20
21// ---------------------------------------------------------------------------
22
23export {
24 MockProxy
25}
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 @@
1import { Express } from 'express'
2import { Server } from 'http'
3import { AddressInfo } from 'net'
4
5function randomListen (app: Express) {
6 return new Promise<Server>(res => {
7 const server = app.listen(0, () => res(server))
8 })
9}
10
11function getPort (server: Server) {
12 const address = server.address() as AddressInfo
13
14 return address.port
15}
16
17function terminateServer (server: Server) {
18 if (!server) return Promise.resolve()
19
20 return new Promise<void>((res, rej) => {
21 server.close(err => {
22 if (err) return rej(err)
23
24 return res()
25 })
26 })
27}
28
29export {
30 randomListen,
31 getPort,
32 terminateServer
33}
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 @@
1import { pick } from '@shared/core-utils'
2import {
3 AbuseFilter,
4 AbuseMessage,
5 AbusePredefinedReasonsString,
6 AbuseState,
7 AbuseUpdate,
8 AbuseVideoIs,
9 AdminAbuse,
10 HttpStatusCode,
11 ResultList,
12 UserAbuse
13} from '@shared/models'
14import { unwrapBody } from '../requests/requests'
15import { AbstractCommand, OverrideCommandOptions } from '../shared'
16
17export class AbusesCommand extends AbstractCommand {
18
19 report (options: OverrideCommandOptions & {
20 reason: string
21
22 accountId?: number
23 videoId?: number
24 commentId?: number
25
26 predefinedReasons?: AbusePredefinedReasonsString[]
27
28 startAt?: number
29 endAt?: number
30 }) {
31 const path = '/api/v1/abuses'
32
33 const video = options.videoId
34 ? {
35 id: options.videoId,
36 startAt: options.startAt,
37 endAt: options.endAt
38 }
39 : undefined
40
41 const comment = options.commentId
42 ? { id: options.commentId }
43 : undefined
44
45 const account = options.accountId
46 ? { id: options.accountId }
47 : undefined
48
49 const body = {
50 account,
51 video,
52 comment,
53
54 reason: options.reason,
55 predefinedReasons: options.predefinedReasons
56 }
57
58 return unwrapBody<{ abuse: { id: number } }>(this.postBodyRequest({
59 ...options,
60
61 path,
62 fields: body,
63 implicitToken: true,
64 defaultExpectedStatus: HttpStatusCode.OK_200
65 }))
66 }
67
68 getAdminList (options: OverrideCommandOptions & {
69 start?: number
70 count?: number
71 sort?: string
72
73 id?: number
74 predefinedReason?: AbusePredefinedReasonsString
75 search?: string
76 filter?: AbuseFilter
77 state?: AbuseState
78 videoIs?: AbuseVideoIs
79 searchReporter?: string
80 searchReportee?: string
81 searchVideo?: string
82 searchVideoChannel?: string
83 } = {}) {
84 const toPick: (keyof typeof options)[] = [
85 'count',
86 'filter',
87 'id',
88 'predefinedReason',
89 'search',
90 'searchReportee',
91 'searchReporter',
92 'searchVideo',
93 'searchVideoChannel',
94 'sort',
95 'start',
96 'state',
97 'videoIs'
98 ]
99
100 const path = '/api/v1/abuses'
101
102 const defaultQuery = { sort: 'createdAt' }
103 const query = { ...defaultQuery, ...pick(options, toPick) }
104
105 return this.getRequestBody<ResultList<AdminAbuse>>({
106 ...options,
107
108 path,
109 query,
110 implicitToken: true,
111 defaultExpectedStatus: HttpStatusCode.OK_200
112 })
113 }
114
115 getUserList (options: OverrideCommandOptions & {
116 start?: number
117 count?: number
118 sort?: string
119
120 id?: number
121 search?: string
122 state?: AbuseState
123 }) {
124 const toPick: (keyof typeof options)[] = [
125 'id',
126 'search',
127 'state',
128 'start',
129 'count',
130 'sort'
131 ]
132
133 const path = '/api/v1/users/me/abuses'
134
135 const defaultQuery = { sort: 'createdAt' }
136 const query = { ...defaultQuery, ...pick(options, toPick) }
137
138 return this.getRequestBody<ResultList<UserAbuse>>({
139 ...options,
140
141 path,
142 query,
143 implicitToken: true,
144 defaultExpectedStatus: HttpStatusCode.OK_200
145 })
146 }
147
148 update (options: OverrideCommandOptions & {
149 abuseId: number
150 body: AbuseUpdate
151 }) {
152 const { abuseId, body } = options
153 const path = '/api/v1/abuses/' + abuseId
154
155 return this.putBodyRequest({
156 ...options,
157
158 path,
159 fields: body,
160 implicitToken: true,
161 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
162 })
163 }
164
165 delete (options: OverrideCommandOptions & {
166 abuseId: number
167 }) {
168 const { abuseId } = options
169 const path = '/api/v1/abuses/' + abuseId
170
171 return this.deleteRequest({
172 ...options,
173
174 path,
175 implicitToken: true,
176 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
177 })
178 }
179
180 listMessages (options: OverrideCommandOptions & {
181 abuseId: number
182 }) {
183 const { abuseId } = options
184 const path = '/api/v1/abuses/' + abuseId + '/messages'
185
186 return this.getRequestBody<ResultList<AbuseMessage>>({
187 ...options,
188
189 path,
190 implicitToken: true,
191 defaultExpectedStatus: HttpStatusCode.OK_200
192 })
193 }
194
195 deleteMessage (options: OverrideCommandOptions & {
196 abuseId: number
197 messageId: number
198 }) {
199 const { abuseId, messageId } = options
200 const path = '/api/v1/abuses/' + abuseId + '/messages/' + messageId
201
202 return this.deleteRequest({
203 ...options,
204
205 path,
206 implicitToken: true,
207 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
208 })
209 }
210
211 addMessage (options: OverrideCommandOptions & {
212 abuseId: number
213 message: string
214 }) {
215 const { abuseId, message } = options
216 const path = '/api/v1/abuses/' + abuseId + '/messages'
217
218 return this.postBodyRequest({
219 ...options,
220
221 path,
222 fields: { message },
223 implicitToken: true,
224 defaultExpectedStatus: HttpStatusCode.OK_200
225 })
226 }
227
228}
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 @@
1import { HttpStatusCode, VideosOverview } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class OverviewsCommand extends AbstractCommand {
5
6 getVideos (options: OverrideCommandOptions & {
7 page: number
8 }) {
9 const { page } = options
10 const path = '/api/v1/overviews/videos'
11
12 const query = { page }
13
14 return this.getRequestBody<VideosOverview>({
15 ...options,
16
17 path,
18 query,
19 implicitToken: false,
20 defaultExpectedStatus: HttpStatusCode.OK_200
21 })
22 }
23}
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 @@
1import { HttpStatusCode } from '@shared/models'
2import { makeGetRequest } from './requests'
3
4function checkBadStartPagination (url: string, path: string, token?: string, query = {}) {
5 return makeGetRequest({
6 url,
7 path,
8 token,
9 query: { ...query, start: 'hello' },
10 expectedStatus: HttpStatusCode.BAD_REQUEST_400
11 })
12}
13
14async function checkBadCountPagination (url: string, path: string, token?: string, query = {}) {
15 await makeGetRequest({
16 url,
17 path,
18 token,
19 query: { ...query, count: 'hello' },
20 expectedStatus: HttpStatusCode.BAD_REQUEST_400
21 })
22
23 await makeGetRequest({
24 url,
25 path,
26 token,
27 query: { ...query, count: 2000 },
28 expectedStatus: HttpStatusCode.BAD_REQUEST_400
29 })
30}
31
32function checkBadSortPagination (url: string, path: string, token?: string, query = {}) {
33 return makeGetRequest({
34 url,
35 path,
36 token,
37 query: { ...query, sort: 'hello' },
38 expectedStatus: HttpStatusCode.BAD_REQUEST_400
39 })
40}
41
42// ---------------------------------------------------------------------------
43
44export {
45 checkBadStartPagination,
46 checkBadCountPagination,
47 checkBadSortPagination
48}
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 @@
1// Don't include activitypub that import stuff from server
2export * from './check-api-params'
3export * 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 @@
1/* eslint-disable @typescript-eslint/no-floating-promises */
2
3import { decode } from 'querystring'
4import request from 'supertest'
5import { URL } from 'url'
6import { HttpStatusCode } from '@shared/models'
7import { buildAbsoluteFixturePath } from '../miscs/tests'
8
9export type CommonRequestParams = {
10 url: string
11 path?: string
12 contentType?: string
13 range?: string
14 redirects?: number
15 accept?: string
16 host?: string
17 token?: string
18 headers?: { [ name: string ]: string }
19 type?: string
20 xForwardedFor?: string
21 expectedStatus?: HttpStatusCode
22}
23
24function makeRawRequest (url: string, expectedStatus?: HttpStatusCode, range?: string) {
25 const { host, protocol, pathname } = new URL(url)
26
27 return makeGetRequest({ url: `${protocol}//${host}`, path: pathname, expectedStatus, range })
28}
29
30function makeGetRequest (options: CommonRequestParams & {
31 query?: any
32 rawQuery?: string
33}) {
34 const req = request(options.url).get(options.path)
35
36 if (options.query) req.query(options.query)
37 if (options.rawQuery) req.query(options.rawQuery)
38
39 return buildRequest(req, { contentType: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
40}
41
42function makeHTMLRequest (url: string, path: string) {
43 return makeGetRequest({
44 url,
45 path,
46 accept: 'text/html',
47 expectedStatus: HttpStatusCode.OK_200
48 })
49}
50
51function makeActivityPubGetRequest (url: string, path: string, expectedStatus = HttpStatusCode.OK_200) {
52 return makeGetRequest({
53 url,
54 path,
55 expectedStatus: expectedStatus,
56 accept: 'application/activity+json,text/html;q=0.9,\\*/\\*;q=0.8'
57 })
58}
59
60function makeDeleteRequest (options: CommonRequestParams & {
61 query?: any
62 rawQuery?: string
63}) {
64 const req = request(options.url).delete(options.path)
65
66 if (options.query) req.query(options.query)
67 if (options.rawQuery) req.query(options.rawQuery)
68
69 return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
70}
71
72function makeUploadRequest (options: CommonRequestParams & {
73 method?: 'POST' | 'PUT'
74
75 fields: { [ fieldName: string ]: any }
76 attaches?: { [ attachName: string ]: any | any[] }
77}) {
78 let req = options.method === 'PUT'
79 ? request(options.url).put(options.path)
80 : request(options.url).post(options.path)
81
82 req = buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
83
84 buildFields(req, options.fields)
85
86 Object.keys(options.attaches || {}).forEach(attach => {
87 const value = options.attaches[attach]
88 if (!value) return
89
90 if (Array.isArray(value)) {
91 req.attach(attach, buildAbsoluteFixturePath(value[0]), value[1])
92 } else {
93 req.attach(attach, buildAbsoluteFixturePath(value))
94 }
95 })
96
97 return req
98}
99
100function makePostBodyRequest (options: CommonRequestParams & {
101 fields?: { [ fieldName: string ]: any }
102}) {
103 const req = request(options.url).post(options.path)
104 .send(options.fields)
105
106 return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
107}
108
109function makePutBodyRequest (options: {
110 url: string
111 path: string
112 token?: string
113 fields: { [ fieldName: string ]: any }
114 expectedStatus?: HttpStatusCode
115}) {
116 const req = request(options.url).put(options.path)
117 .send(options.fields)
118
119 return buildRequest(req, { accept: 'application/json', expectedStatus: HttpStatusCode.BAD_REQUEST_400, ...options })
120}
121
122function decodeQueryString (path: string) {
123 return decode(path.split('?')[1])
124}
125
126function unwrapBody <T> (test: request.Test): Promise<T> {
127 return test.then(res => res.body)
128}
129
130function unwrapText (test: request.Test): Promise<string> {
131 return test.then(res => res.text)
132}
133
134function unwrapBodyOrDecodeToJSON <T> (test: request.Test): Promise<T> {
135 return test.then(res => {
136 if (res.body instanceof Buffer) {
137 return JSON.parse(new TextDecoder().decode(res.body))
138 }
139
140 return res.body
141 })
142}
143
144function unwrapTextOrDecode (test: request.Test): Promise<string> {
145 return test.then(res => res.text || new TextDecoder().decode(res.body))
146}
147
148// ---------------------------------------------------------------------------
149
150export {
151 makeHTMLRequest,
152 makeGetRequest,
153 decodeQueryString,
154 makeUploadRequest,
155 makePostBodyRequest,
156 makePutBodyRequest,
157 makeDeleteRequest,
158 makeRawRequest,
159 makeActivityPubGetRequest,
160 unwrapBody,
161 unwrapTextOrDecode,
162 unwrapBodyOrDecodeToJSON,
163 unwrapText
164}
165
166// ---------------------------------------------------------------------------
167
168function buildRequest (req: request.Test, options: CommonRequestParams) {
169 if (options.contentType) req.set('Accept', options.contentType)
170 if (options.token) req.set('Authorization', 'Bearer ' + options.token)
171 if (options.range) req.set('Range', options.range)
172 if (options.accept) req.set('Accept', options.accept)
173 if (options.host) req.set('Host', options.host)
174 if (options.redirects) req.redirects(options.redirects)
175 if (options.expectedStatus) req.expect(options.expectedStatus)
176 if (options.xForwardedFor) req.set('X-Forwarded-For', options.xForwardedFor)
177 if (options.type) req.type(options.type)
178
179 Object.keys(options.headers || {}).forEach(name => {
180 req.set(name, options.headers[name])
181 })
182
183 return req
184}
185
186function buildFields (req: request.Test, fields: { [ fieldName: string ]: any }, namespace?: string) {
187 if (!fields) return
188
189 let formKey: string
190
191 for (const key of Object.keys(fields)) {
192 if (namespace) formKey = `${namespace}[${key}]`
193 else formKey = key
194
195 if (fields[key] === undefined) continue
196
197 if (Array.isArray(fields[key]) && fields[key].length === 0) {
198 req.field(key, [])
199 continue
200 }
201
202 if (fields[key] !== null && typeof fields[key] === 'object') {
203 buildFields(req, fields[key], formKey)
204 } else {
205 req.field(formKey, fields[key])
206 }
207 }
208}
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 @@
1import {
2 HttpStatusCode,
3 ResultList,
4 Video,
5 VideoChannel,
6 VideoChannelsSearchQuery,
7 VideoPlaylist,
8 VideoPlaylistsSearchQuery,
9 VideosSearchQuery
10} from '@shared/models'
11import { AbstractCommand, OverrideCommandOptions } from '../shared'
12
13export class SearchCommand extends AbstractCommand {
14
15 searchChannels (options: OverrideCommandOptions & {
16 search: string
17 }) {
18 return this.advancedChannelSearch({
19 ...options,
20
21 search: { search: options.search }
22 })
23 }
24
25 advancedChannelSearch (options: OverrideCommandOptions & {
26 search: VideoChannelsSearchQuery
27 }) {
28 const { search } = options
29 const path = '/api/v1/search/video-channels'
30
31 return this.getRequestBody<ResultList<VideoChannel>>({
32 ...options,
33
34 path,
35 query: search,
36 implicitToken: false,
37 defaultExpectedStatus: HttpStatusCode.OK_200
38 })
39 }
40
41 searchPlaylists (options: OverrideCommandOptions & {
42 search: string
43 }) {
44 return this.advancedPlaylistSearch({
45 ...options,
46
47 search: { search: options.search }
48 })
49 }
50
51 advancedPlaylistSearch (options: OverrideCommandOptions & {
52 search: VideoPlaylistsSearchQuery
53 }) {
54 const { search } = options
55 const path = '/api/v1/search/video-playlists'
56
57 return this.getRequestBody<ResultList<VideoPlaylist>>({
58 ...options,
59
60 path,
61 query: search,
62 implicitToken: false,
63 defaultExpectedStatus: HttpStatusCode.OK_200
64 })
65 }
66
67 searchVideos (options: OverrideCommandOptions & {
68 search: string
69 sort?: string
70 }) {
71 const { search, sort } = options
72
73 return this.advancedVideoSearch({
74 ...options,
75
76 search: {
77 search: search,
78 sort: sort ?? '-publishedAt'
79 }
80 })
81 }
82
83 advancedVideoSearch (options: OverrideCommandOptions & {
84 search: VideosSearchQuery
85 }) {
86 const { search } = options
87 const path = '/api/v1/search/videos'
88
89 return this.getRequestBody<ResultList<Video>>({
90 ...options,
91
92 path,
93 query: search,
94 implicitToken: false,
95 defaultExpectedStatus: HttpStatusCode.OK_200
96 })
97 }
98}
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 @@
1import { merge } from 'lodash'
2import { DeepPartial } from '@shared/typescript-utils'
3import { About, HttpStatusCode, ServerConfig } from '@shared/models'
4import { CustomConfig } from '../../models/server/custom-config.model'
5import { AbstractCommand, OverrideCommandOptions } from '../shared'
6
7export class ConfigCommand extends AbstractCommand {
8
9 static getCustomConfigResolutions (enabled: boolean) {
10 return {
11 '144p': enabled,
12 '240p': enabled,
13 '360p': enabled,
14 '480p': enabled,
15 '720p': enabled,
16 '1080p': enabled,
17 '1440p': enabled,
18 '2160p': enabled
19 }
20 }
21
22 enableImports () {
23 return this.updateExistingSubConfig({
24 newConfig: {
25 import: {
26 videos: {
27 http: {
28 enabled: true
29 },
30
31 torrent: {
32 enabled: true
33 }
34 }
35 }
36 }
37 })
38 }
39
40 enableLive (options: {
41 allowReplay?: boolean
42 transcoding?: boolean
43 } = {}) {
44 return this.updateExistingSubConfig({
45 newConfig: {
46 live: {
47 enabled: true,
48 allowReplay: options.allowReplay ?? true,
49 transcoding: {
50 enabled: options.transcoding ?? true,
51 resolutions: ConfigCommand.getCustomConfigResolutions(true)
52 }
53 }
54 }
55 })
56 }
57
58 disableTranscoding () {
59 return this.updateExistingSubConfig({
60 newConfig: {
61 transcoding: {
62 enabled: false
63 }
64 }
65 })
66 }
67
68 enableTranscoding (webtorrent = true, hls = true) {
69 return this.updateExistingSubConfig({
70 newConfig: {
71 transcoding: {
72 enabled: true,
73 resolutions: ConfigCommand.getCustomConfigResolutions(true),
74
75 webtorrent: {
76 enabled: webtorrent
77 },
78 hls: {
79 enabled: hls
80 }
81 }
82 }
83 })
84 }
85
86 getConfig (options: OverrideCommandOptions = {}) {
87 const path = '/api/v1/config'
88
89 return this.getRequestBody<ServerConfig>({
90 ...options,
91
92 path,
93 implicitToken: false,
94 defaultExpectedStatus: HttpStatusCode.OK_200
95 })
96 }
97
98 getAbout (options: OverrideCommandOptions = {}) {
99 const path = '/api/v1/config/about'
100
101 return this.getRequestBody<About>({
102 ...options,
103
104 path,
105 implicitToken: false,
106 defaultExpectedStatus: HttpStatusCode.OK_200
107 })
108 }
109
110 getCustomConfig (options: OverrideCommandOptions = {}) {
111 const path = '/api/v1/config/custom'
112
113 return this.getRequestBody<CustomConfig>({
114 ...options,
115
116 path,
117 implicitToken: true,
118 defaultExpectedStatus: HttpStatusCode.OK_200
119 })
120 }
121
122 updateCustomConfig (options: OverrideCommandOptions & {
123 newCustomConfig: CustomConfig
124 }) {
125 const path = '/api/v1/config/custom'
126
127 return this.putBodyRequest({
128 ...options,
129
130 path,
131 fields: options.newCustomConfig,
132 implicitToken: true,
133 defaultExpectedStatus: HttpStatusCode.OK_200
134 })
135 }
136
137 deleteCustomConfig (options: OverrideCommandOptions = {}) {
138 const path = '/api/v1/config/custom'
139
140 return this.deleteRequest({
141 ...options,
142
143 path,
144 implicitToken: true,
145 defaultExpectedStatus: HttpStatusCode.OK_200
146 })
147 }
148
149 async updateExistingSubConfig (options: OverrideCommandOptions & {
150 newConfig: DeepPartial<CustomConfig>
151 }) {
152 const existing = await this.getCustomConfig(options)
153
154 return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) })
155 }
156
157 updateCustomSubConfig (options: OverrideCommandOptions & {
158 newConfig: DeepPartial<CustomConfig>
159 }) {
160 const newCustomConfig: CustomConfig = {
161 instance: {
162 name: 'PeerTube updated',
163 shortDescription: 'my short description',
164 description: 'my super description',
165 terms: 'my super terms',
166 codeOfConduct: 'my super coc',
167
168 creationReason: 'my super creation reason',
169 moderationInformation: 'my super moderation information',
170 administrator: 'Kuja',
171 maintenanceLifetime: 'forever',
172 businessModel: 'my super business model',
173 hardwareInformation: '2vCore 3GB RAM',
174
175 languages: [ 'en', 'es' ],
176 categories: [ 1, 2 ],
177
178 isNSFW: true,
179 defaultNSFWPolicy: 'blur',
180
181 defaultClientRoute: '/videos/recently-added',
182
183 customizations: {
184 javascript: 'alert("coucou")',
185 css: 'body { background-color: red; }'
186 }
187 },
188 theme: {
189 default: 'default'
190 },
191 services: {
192 twitter: {
193 username: '@MySuperUsername',
194 whitelisted: true
195 }
196 },
197 client: {
198 videos: {
199 miniature: {
200 preferAuthorDisplayName: false
201 }
202 },
203 menu: {
204 login: {
205 redirectOnSingleExternalAuth: false
206 }
207 }
208 },
209 cache: {
210 previews: {
211 size: 2
212 },
213 captions: {
214 size: 3
215 },
216 torrents: {
217 size: 4
218 }
219 },
220 signup: {
221 enabled: false,
222 limit: 5,
223 requiresEmailVerification: false,
224 minimumAge: 16
225 },
226 admin: {
227 email: 'superadmin1@example.com'
228 },
229 contactForm: {
230 enabled: true
231 },
232 user: {
233 videoQuota: 5242881,
234 videoQuotaDaily: 318742
235 },
236 videoChannels: {
237 maxPerUser: 20
238 },
239 transcoding: {
240 enabled: true,
241 allowAdditionalExtensions: true,
242 allowAudioFiles: true,
243 threads: 1,
244 concurrency: 3,
245 profile: 'default',
246 resolutions: {
247 '0p': false,
248 '144p': false,
249 '240p': false,
250 '360p': true,
251 '480p': true,
252 '720p': false,
253 '1080p': false,
254 '1440p': false,
255 '2160p': false
256 },
257 webtorrent: {
258 enabled: true
259 },
260 hls: {
261 enabled: false
262 }
263 },
264 live: {
265 enabled: true,
266 allowReplay: false,
267 maxDuration: -1,
268 maxInstanceLives: -1,
269 maxUserLives: 50,
270 transcoding: {
271 enabled: true,
272 threads: 4,
273 profile: 'default',
274 resolutions: {
275 '144p': true,
276 '240p': true,
277 '360p': true,
278 '480p': true,
279 '720p': true,
280 '1080p': true,
281 '1440p': true,
282 '2160p': true
283 }
284 }
285 },
286 import: {
287 videos: {
288 concurrency: 3,
289 http: {
290 enabled: false
291 },
292 torrent: {
293 enabled: false
294 }
295 }
296 },
297 trending: {
298 videos: {
299 algorithms: {
300 enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ],
301 default: 'hot'
302 }
303 }
304 },
305 autoBlacklist: {
306 videos: {
307 ofUsers: {
308 enabled: false
309 }
310 }
311 },
312 followers: {
313 instance: {
314 enabled: true,
315 manualApproval: false
316 }
317 },
318 followings: {
319 instance: {
320 autoFollowBack: {
321 enabled: false
322 },
323 autoFollowIndex: {
324 indexUrl: 'https://instances.joinpeertube.org/api/v1/instances/hosts',
325 enabled: false
326 }
327 }
328 },
329 broadcastMessage: {
330 enabled: true,
331 level: 'warning',
332 message: 'hello',
333 dismissable: true
334 },
335 search: {
336 remoteUri: {
337 users: true,
338 anonymous: true
339 },
340 searchIndex: {
341 enabled: true,
342 url: 'https://search.joinpeertube.org',
343 disableLocalSearch: true,
344 isDefaultSearch: true
345 }
346 }
347 }
348
349 merge(newCustomConfig, options.newConfig)
350
351 return this.updateCustomConfig({ ...options, newCustomConfig })
352 }
353}
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 @@
1import { HttpStatusCode } from '@shared/models'
2import { ContactForm } from '../../models/server'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class ContactFormCommand extends AbstractCommand {
6
7 send (options: OverrideCommandOptions & {
8 fromEmail: string
9 fromName: string
10 subject: string
11 body: string
12 }) {
13 const path = '/api/v1/server/contact'
14
15 const body: ContactForm = {
16 fromEmail: options.fromEmail,
17 fromName: options.fromName,
18 subject: options.subject,
19 body: options.body
20 }
21
22 return this.postBodyRequest({
23 ...options,
24
25 path,
26 fields: body,
27 implicitToken: false,
28 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
29 })
30 }
31}
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 @@
1import { Debug, HttpStatusCode, SendDebugCommand } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class DebugCommand extends AbstractCommand {
5
6 getDebug (options: OverrideCommandOptions = {}) {
7 const path = '/api/v1/server/debug'
8
9 return this.getRequestBody<Debug>({
10 ...options,
11
12 path,
13 implicitToken: true,
14 defaultExpectedStatus: HttpStatusCode.OK_200
15 })
16 }
17
18 sendCommand (options: OverrideCommandOptions & {
19 body: SendDebugCommand
20 }) {
21 const { body } = options
22 const path = '/api/v1/server/debug/run-command'
23
24 return this.postBodyRequest({
25 ...options,
26
27 path,
28 fields: body,
29 implicitToken: true,
30 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
31 })
32 }
33}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { pathExists, readdir } from 'fs-extra'
5import { join } from 'path'
6import { root } from '@shared/core-utils'
7import { PeerTubeServer } from './server'
8
9async function checkTmpIsEmpty (server: PeerTubeServer) {
10 await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls', 'resumable-uploads' ])
11
12 if (await pathExists(join('test' + server.internalServerNumber, 'tmp', 'hls'))) {
13 await checkDirectoryIsEmpty(server, 'tmp/hls')
14 }
15}
16
17async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) {
18 const testDirectory = 'test' + server.internalServerNumber
19
20 const directoryPath = join(root(), testDirectory, directory)
21
22 const directoryExists = await pathExists(directoryPath)
23 expect(directoryExists).to.be.true
24
25 const files = await readdir(directoryPath)
26 const filtered = files.filter(f => exceptions.includes(f) === false)
27
28 expect(filtered).to.have.lengthOf(0)
29}
30
31export {
32 checkTmpIsEmpty,
33 checkDirectoryIsEmpty
34}
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 @@
1import { pick } from '@shared/core-utils'
2import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4import { PeerTubeServer } from './server'
5
6export class FollowsCommand extends AbstractCommand {
7
8 getFollowers (options: OverrideCommandOptions & {
9 start: number
10 count: number
11 sort: string
12 search?: string
13 actorType?: ActivityPubActorType
14 state?: FollowState
15 }) {
16 const path = '/api/v1/server/followers'
17
18 const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ])
19
20 return this.getRequestBody<ResultList<ActorFollow>>({
21 ...options,
22
23 path,
24 query,
25 implicitToken: false,
26 defaultExpectedStatus: HttpStatusCode.OK_200
27 })
28 }
29
30 getFollowings (options: OverrideCommandOptions & {
31 start?: number
32 count?: number
33 sort?: string
34 search?: string
35 actorType?: ActivityPubActorType
36 state?: FollowState
37 } = {}) {
38 const path = '/api/v1/server/following'
39
40 const query = pick(options, [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ])
41
42 return this.getRequestBody<ResultList<ActorFollow>>({
43 ...options,
44
45 path,
46 query,
47 implicitToken: false,
48 defaultExpectedStatus: HttpStatusCode.OK_200
49 })
50 }
51
52 follow (options: OverrideCommandOptions & {
53 hosts?: string[]
54 handles?: string[]
55 }) {
56 const path = '/api/v1/server/following'
57
58 const fields: ServerFollowCreate = {}
59
60 if (options.hosts) {
61 fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, ''))
62 }
63
64 if (options.handles) {
65 fields.handles = options.handles
66 }
67
68 return this.postBodyRequest({
69 ...options,
70
71 path,
72 fields,
73 implicitToken: true,
74 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
75 })
76 }
77
78 async unfollow (options: OverrideCommandOptions & {
79 target: PeerTubeServer | string
80 }) {
81 const { target } = options
82
83 const handle = typeof target === 'string'
84 ? target
85 : target.host
86
87 const path = '/api/v1/server/following/' + handle
88
89 return this.deleteRequest({
90 ...options,
91
92 path,
93 implicitToken: true,
94 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
95 })
96 }
97
98 acceptFollower (options: OverrideCommandOptions & {
99 follower: string
100 }) {
101 const path = '/api/v1/server/followers/' + options.follower + '/accept'
102
103 return this.postBodyRequest({
104 ...options,
105
106 path,
107 implicitToken: true,
108 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
109 })
110 }
111
112 rejectFollower (options: OverrideCommandOptions & {
113 follower: string
114 }) {
115 const path = '/api/v1/server/followers/' + options.follower + '/reject'
116
117 return this.postBodyRequest({
118 ...options,
119
120 path,
121 implicitToken: true,
122 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
123 })
124 }
125
126 removeFollower (options: OverrideCommandOptions & {
127 follower: PeerTubeServer
128 }) {
129 const path = '/api/v1/server/followers/peertube@' + options.follower.host
130
131 return this.deleteRequest({
132 ...options,
133
134 path,
135 implicitToken: true,
136 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
137 })
138 }
139}
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 @@
1import { waitJobs } from './jobs'
2import { PeerTubeServer } from './server'
3
4async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
5 await Promise.all([
6 server1.follows.follow({ hosts: [ server2.url ] }),
7 server2.follows.follow({ hosts: [ server1.url ] })
8 ])
9
10 // Wait request propagation
11 await waitJobs([ server1, server2 ])
12
13 return true
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 doubleFollow
20}
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 @@
1export * from './config-command'
2export * from './contact-form-command'
3export * from './debug-command'
4export * from './directories'
5export * from './follows-command'
6export * from './follows'
7export * from './jobs'
8export * from './jobs-command'
9export * from './object-storage-command'
10export * from './plugins-command'
11export * from './plugins'
12export * from './redundancy-command'
13export * from './server'
14export * from './servers-command'
15export * from './servers'
16export * from './stats-command'
17export * 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 @@
1import { pick } from '@shared/core-utils'
2import { HttpStatusCode } from '@shared/models'
3import { Job, JobState, JobType, ResultList } from '../../models'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class JobsCommand extends AbstractCommand {
7
8 async getLatest (options: OverrideCommandOptions & {
9 jobType: JobType
10 }) {
11 const { data } = await this.list({ ...options, start: 0, count: 1, sort: '-createdAt' })
12
13 if (data.length === 0) return undefined
14
15 return data[0]
16 }
17
18 list (options: OverrideCommandOptions & {
19 state?: JobState
20 jobType?: JobType
21 start?: number
22 count?: number
23 sort?: string
24 } = {}) {
25 const path = this.buildJobsUrl(options.state)
26
27 const query = pick(options, [ 'start', 'count', 'sort', 'jobType' ])
28
29 return this.getRequestBody<ResultList<Job>>({
30 ...options,
31
32 path,
33 query,
34 implicitToken: true,
35 defaultExpectedStatus: HttpStatusCode.OK_200
36 })
37 }
38
39 listFailed (options: OverrideCommandOptions & {
40 jobType?: JobType
41 }) {
42 const path = this.buildJobsUrl('failed')
43
44 return this.getRequestBody<ResultList<Job>>({
45 ...options,
46
47 path,
48 query: { start: 0, count: 50 },
49 implicitToken: true,
50 defaultExpectedStatus: HttpStatusCode.OK_200
51 })
52 }
53
54 private buildJobsUrl (state?: JobState) {
55 let path = '/api/v1/jobs'
56
57 if (state) path += '/' + state
58
59 return path
60 }
61}
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 @@
1
2import { expect } from 'chai'
3import { JobState, JobType } from '../../models'
4import { wait } from '../miscs'
5import { PeerTubeServer } from './server'
6
7async function waitJobs (serversArg: PeerTubeServer[] | PeerTubeServer, skipDelayed = false) {
8 const pendingJobWait = process.env.NODE_PENDING_JOB_WAIT
9 ? parseInt(process.env.NODE_PENDING_JOB_WAIT, 10)
10 : 250
11
12 let servers: PeerTubeServer[]
13
14 if (Array.isArray(serversArg) === false) servers = [ serversArg as PeerTubeServer ]
15 else servers = serversArg as PeerTubeServer[]
16
17 const states: JobState[] = [ 'waiting', 'active' ]
18 if (!skipDelayed) states.push('delayed')
19
20 const repeatableJobs: JobType[] = [ 'videos-views-stats', 'activitypub-cleaner' ]
21 let pendingRequests: boolean
22
23 function tasksBuilder () {
24 const tasks: Promise<any>[] = []
25
26 // Check if each server has pending request
27 for (const server of servers) {
28 for (const state of states) {
29 const p = server.jobs.list({
30 state,
31 start: 0,
32 count: 10,
33 sort: '-createdAt'
34 }).then(body => body.data)
35 .then(jobs => jobs.filter(j => !repeatableJobs.includes(j.type)))
36 .then(jobs => {
37 if (jobs.length !== 0) {
38 pendingRequests = true
39 }
40 })
41
42 tasks.push(p)
43 }
44
45 const p = server.debug.getDebug()
46 .then(obj => {
47 if (obj.activityPubMessagesWaiting !== 0) {
48 pendingRequests = true
49 }
50 })
51
52 tasks.push(p)
53 }
54
55 return tasks
56 }
57
58 do {
59 pendingRequests = false
60 await Promise.all(tasksBuilder())
61
62 // Retry, in case of new jobs were created
63 if (pendingRequests === false) {
64 await wait(pendingJobWait)
65 await Promise.all(tasksBuilder())
66 }
67
68 if (pendingRequests) {
69 await wait(pendingJobWait)
70 }
71 } while (pendingRequests)
72}
73
74async function expectNoFailedTranscodingJob (server: PeerTubeServer) {
75 const { data } = await server.jobs.listFailed({ jobType: 'video-transcoding' })
76 expect(data).to.have.lengthOf(0)
77}
78
79// ---------------------------------------------------------------------------
80
81export {
82 waitJobs,
83 expectNoFailedTranscodingJob
84}
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 @@
1
2import { HttpStatusCode } from '@shared/models'
3import { makePostBodyRequest } from '../requests'
4import { AbstractCommand } from '../shared'
5
6export class ObjectStorageCommand extends AbstractCommand {
7 static readonly DEFAULT_PLAYLIST_BUCKET = 'streaming-playlists'
8 static readonly DEFAULT_WEBTORRENT_BUCKET = 'videos'
9
10 static getDefaultConfig () {
11 return {
12 object_storage: {
13 enabled: true,
14 endpoint: 'http://' + this.getEndpointHost(),
15 region: this.getRegion(),
16
17 credentials: this.getCredentialsConfig(),
18
19 streaming_playlists: {
20 bucket_name: this.DEFAULT_PLAYLIST_BUCKET
21 },
22
23 videos: {
24 bucket_name: this.DEFAULT_WEBTORRENT_BUCKET
25 }
26 }
27 }
28 }
29
30 static getCredentialsConfig () {
31 return {
32 access_key_id: 'AKIAIOSFODNN7EXAMPLE',
33 secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
34 }
35 }
36
37 static getEndpointHost () {
38 return 'localhost:9444'
39 }
40
41 static getRegion () {
42 return 'us-east-1'
43 }
44
45 static getWebTorrentBaseUrl () {
46 return `http://${this.DEFAULT_WEBTORRENT_BUCKET}.${this.getEndpointHost()}/`
47 }
48
49 static getPlaylistBaseUrl () {
50 return `http://${this.DEFAULT_PLAYLIST_BUCKET}.${this.getEndpointHost()}/`
51 }
52
53 static async prepareDefaultBuckets () {
54 await this.createBucket(this.DEFAULT_PLAYLIST_BUCKET)
55 await this.createBucket(this.DEFAULT_WEBTORRENT_BUCKET)
56 }
57
58 static async createBucket (name: string) {
59 await makePostBodyRequest({
60 url: this.getEndpointHost(),
61 path: '/ui/' + name + '?delete',
62 expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307
63 })
64
65 await makePostBodyRequest({
66 url: this.getEndpointHost(),
67 path: '/ui/' + name + '?create',
68 expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307
69 })
70
71 await makePostBodyRequest({
72 url: this.getEndpointHost(),
73 path: '/ui/' + name + '?make-public',
74 expectedStatus: HttpStatusCode.TEMPORARY_REDIRECT_307
75 })
76 }
77}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { readJSON, writeJSON } from 'fs-extra'
4import { join } from 'path'
5import { root } from '@shared/core-utils'
6import {
7 HttpStatusCode,
8 PeerTubePlugin,
9 PeerTubePluginIndex,
10 PeertubePluginIndexList,
11 PluginPackageJson,
12 PluginTranslation,
13 PluginType,
14 PublicServerSetting,
15 RegisteredServerSettings,
16 ResultList
17} from '@shared/models'
18import { AbstractCommand, OverrideCommandOptions } from '../shared'
19
20export class PluginsCommand extends AbstractCommand {
21
22 static getPluginTestPath (suffix = '') {
23 return join(root(), 'server', 'tests', 'fixtures', 'peertube-plugin-test' + suffix)
24 }
25
26 list (options: OverrideCommandOptions & {
27 start?: number
28 count?: number
29 sort?: string
30 pluginType?: PluginType
31 uninstalled?: boolean
32 }) {
33 const { start, count, sort, pluginType, uninstalled } = options
34 const path = '/api/v1/plugins'
35
36 return this.getRequestBody<ResultList<PeerTubePlugin>>({
37 ...options,
38
39 path,
40 query: {
41 start,
42 count,
43 sort,
44 pluginType,
45 uninstalled
46 },
47 implicitToken: true,
48 defaultExpectedStatus: HttpStatusCode.OK_200
49 })
50 }
51
52 listAvailable (options: OverrideCommandOptions & {
53 start?: number
54 count?: number
55 sort?: string
56 pluginType?: PluginType
57 currentPeerTubeEngine?: string
58 search?: string
59 expectedStatus?: HttpStatusCode
60 }) {
61 const { start, count, sort, pluginType, search, currentPeerTubeEngine } = options
62 const path = '/api/v1/plugins/available'
63
64 const query: PeertubePluginIndexList = {
65 start,
66 count,
67 sort,
68 pluginType,
69 currentPeerTubeEngine,
70 search
71 }
72
73 return this.getRequestBody<ResultList<PeerTubePluginIndex>>({
74 ...options,
75
76 path,
77 query,
78 implicitToken: true,
79 defaultExpectedStatus: HttpStatusCode.OK_200
80 })
81 }
82
83 get (options: OverrideCommandOptions & {
84 npmName: string
85 }) {
86 const path = '/api/v1/plugins/' + options.npmName
87
88 return this.getRequestBody<PeerTubePlugin>({
89 ...options,
90
91 path,
92 implicitToken: true,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 updateSettings (options: OverrideCommandOptions & {
98 npmName: string
99 settings: any
100 }) {
101 const { npmName, settings } = options
102 const path = '/api/v1/plugins/' + npmName + '/settings'
103
104 return this.putBodyRequest({
105 ...options,
106
107 path,
108 fields: { settings },
109 implicitToken: true,
110 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
111 })
112 }
113
114 getRegisteredSettings (options: OverrideCommandOptions & {
115 npmName: string
116 }) {
117 const path = '/api/v1/plugins/' + options.npmName + '/registered-settings'
118
119 return this.getRequestBody<RegisteredServerSettings>({
120 ...options,
121
122 path,
123 implicitToken: true,
124 defaultExpectedStatus: HttpStatusCode.OK_200
125 })
126 }
127
128 getPublicSettings (options: OverrideCommandOptions & {
129 npmName: string
130 }) {
131 const { npmName } = options
132 const path = '/api/v1/plugins/' + npmName + '/public-settings'
133
134 return this.getRequestBody<PublicServerSetting>({
135 ...options,
136
137 path,
138 implicitToken: false,
139 defaultExpectedStatus: HttpStatusCode.OK_200
140 })
141 }
142
143 getTranslations (options: OverrideCommandOptions & {
144 locale: string
145 }) {
146 const { locale } = options
147 const path = '/plugins/translations/' + locale + '.json'
148
149 return this.getRequestBody<PluginTranslation>({
150 ...options,
151
152 path,
153 implicitToken: false,
154 defaultExpectedStatus: HttpStatusCode.OK_200
155 })
156 }
157
158 install (options: OverrideCommandOptions & {
159 path?: string
160 npmName?: string
161 pluginVersion?: string
162 }) {
163 const { npmName, path, pluginVersion } = options
164 const apiPath = '/api/v1/plugins/install'
165
166 return this.postBodyRequest({
167 ...options,
168
169 path: apiPath,
170 fields: { npmName, path, pluginVersion },
171 implicitToken: true,
172 defaultExpectedStatus: HttpStatusCode.OK_200
173 })
174 }
175
176 update (options: OverrideCommandOptions & {
177 path?: string
178 npmName?: string
179 }) {
180 const { npmName, path } = options
181 const apiPath = '/api/v1/plugins/update'
182
183 return this.postBodyRequest({
184 ...options,
185
186 path: apiPath,
187 fields: { npmName, path },
188 implicitToken: true,
189 defaultExpectedStatus: HttpStatusCode.OK_200
190 })
191 }
192
193 uninstall (options: OverrideCommandOptions & {
194 npmName: string
195 }) {
196 const { npmName } = options
197 const apiPath = '/api/v1/plugins/uninstall'
198
199 return this.postBodyRequest({
200 ...options,
201
202 path: apiPath,
203 fields: { npmName },
204 implicitToken: true,
205 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
206 })
207 }
208
209 getCSS (options: OverrideCommandOptions = {}) {
210 const path = '/plugins/global.css'
211
212 return this.getRequestText({
213 ...options,
214
215 path,
216 implicitToken: false,
217 defaultExpectedStatus: HttpStatusCode.OK_200
218 })
219 }
220
221 getExternalAuth (options: OverrideCommandOptions & {
222 npmName: string
223 npmVersion: string
224 authName: string
225 query?: any
226 }) {
227 const { npmName, npmVersion, authName, query } = options
228
229 const path = '/plugins/' + npmName + '/' + npmVersion + '/auth/' + authName
230
231 return this.getRequest({
232 ...options,
233
234 path,
235 query,
236 implicitToken: false,
237 defaultExpectedStatus: HttpStatusCode.OK_200,
238 redirects: 0
239 })
240 }
241
242 updatePackageJSON (npmName: string, json: any) {
243 const path = this.getPackageJSONPath(npmName)
244
245 return writeJSON(path, json)
246 }
247
248 getPackageJSON (npmName: string): Promise<PluginPackageJson> {
249 const path = this.getPackageJSONPath(npmName)
250
251 return readJSON(path)
252 }
253
254 private getPackageJSONPath (npmName: string) {
255 return this.server.servers.buildDirectory(join('plugins', 'node_modules', npmName, 'package.json'))
256 }
257}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { PeerTubeServer } from './server'
5
6async function testHelloWorldRegisteredSettings (server: PeerTubeServer) {
7 const body = await server.plugins.getRegisteredSettings({ npmName: 'peertube-plugin-hello-world' })
8
9 const registeredSettings = body.registeredSettings
10 expect(registeredSettings).to.have.length.at.least(1)
11
12 const adminNameSettings = registeredSettings.find(s => s.name === 'admin-name')
13 expect(adminNameSettings).to.not.be.undefined
14}
15
16export {
17 testHelloWorldRegisteredSettings
18}
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 @@
1import { HttpStatusCode, ResultList, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class RedundancyCommand extends AbstractCommand {
5
6 updateRedundancy (options: OverrideCommandOptions & {
7 host: string
8 redundancyAllowed: boolean
9 }) {
10 const { host, redundancyAllowed } = options
11 const path = '/api/v1/server/redundancy/' + host
12
13 return this.putBodyRequest({
14 ...options,
15
16 path,
17 fields: { redundancyAllowed },
18 implicitToken: true,
19 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
20 })
21 }
22
23 listVideos (options: OverrideCommandOptions & {
24 target: VideoRedundanciesTarget
25 start?: number
26 count?: number
27 sort?: string
28 }) {
29 const path = '/api/v1/server/redundancy/videos'
30
31 const { target, start, count, sort } = options
32
33 return this.getRequestBody<ResultList<VideoRedundancy>>({
34 ...options,
35
36 path,
37
38 query: {
39 start: start ?? 0,
40 count: count ?? 5,
41 sort: sort ?? 'name',
42 target
43 },
44
45 implicitToken: true,
46 defaultExpectedStatus: HttpStatusCode.OK_200
47 })
48 }
49
50 addVideo (options: OverrideCommandOptions & {
51 videoId: number
52 }) {
53 const path = '/api/v1/server/redundancy/videos'
54 const { videoId } = options
55
56 return this.postBodyRequest({
57 ...options,
58
59 path,
60 fields: { videoId },
61 implicitToken: true,
62 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
63 })
64 }
65
66 removeVideo (options: OverrideCommandOptions & {
67 redundancyId: number
68 }) {
69 const { redundancyId } = options
70 const path = '/api/v1/server/redundancy/videos/' + redundancyId
71
72 return this.deleteRequest({
73 ...options,
74
75 path,
76 implicitToken: true,
77 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
78 })
79 }
80}
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 @@
1import { ChildProcess, fork } from 'child_process'
2import { copy } from 'fs-extra'
3import { join } from 'path'
4import { root, randomInt } from '@shared/core-utils'
5import { Video, VideoChannel, VideoCreateResult, VideoDetails } from '../../models/videos'
6import { BulkCommand } from '../bulk'
7import { CLICommand } from '../cli'
8import { CustomPagesCommand } from '../custom-pages'
9import { FeedCommand } from '../feeds'
10import { LogsCommand } from '../logs'
11import { parallelTests, SQLCommand } from '../miscs'
12import { AbusesCommand } from '../moderation'
13import { OverviewsCommand } from '../overviews'
14import { SearchCommand } from '../search'
15import { SocketIOCommand } from '../socket'
16import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users'
17import {
18 BlacklistCommand,
19 CaptionsCommand,
20 ChangeOwnershipCommand,
21 ChannelsCommand,
22 HistoryCommand,
23 ImportsCommand,
24 LiveCommand,
25 PlaylistsCommand,
26 ServicesCommand,
27 StreamingPlaylistsCommand,
28 VideosCommand
29} from '../videos'
30import { CommentsCommand } from '../videos/comments-command'
31import { ConfigCommand } from './config-command'
32import { ContactFormCommand } from './contact-form-command'
33import { DebugCommand } from './debug-command'
34import { FollowsCommand } from './follows-command'
35import { JobsCommand } from './jobs-command'
36import { PluginsCommand } from './plugins-command'
37import { RedundancyCommand } from './redundancy-command'
38import { ServersCommand } from './servers-command'
39import { StatsCommand } from './stats-command'
40import { ObjectStorageCommand } from './object-storage-command'
41
42export type RunServerOptions = {
43 hideLogs?: boolean
44 nodeArgs?: string[]
45 peertubeArgs?: string[]
46 env?: { [ id: string ]: string }
47}
48
49export class PeerTubeServer {
50 app?: ChildProcess
51
52 url: string
53 host?: string
54 hostname?: string
55 port?: number
56
57 rtmpPort?: number
58 rtmpsPort?: number
59
60 parallel?: boolean
61 internalServerNumber: number
62
63 serverNumber?: number
64 customConfigFile?: string
65
66 store?: {
67 client?: {
68 id?: string
69 secret?: string
70 }
71
72 user?: {
73 username: string
74 password: string
75 email?: string
76 }
77
78 channel?: VideoChannel
79
80 video?: Video
81 videoCreated?: VideoCreateResult
82 videoDetails?: VideoDetails
83
84 videos?: { id: number, uuid: string }[]
85 }
86
87 accessToken?: string
88 refreshToken?: string
89
90 bulk?: BulkCommand
91 cli?: CLICommand
92 customPage?: CustomPagesCommand
93 feed?: FeedCommand
94 logs?: LogsCommand
95 abuses?: AbusesCommand
96 overviews?: OverviewsCommand
97 search?: SearchCommand
98 contactForm?: ContactFormCommand
99 debug?: DebugCommand
100 follows?: FollowsCommand
101 jobs?: JobsCommand
102 plugins?: PluginsCommand
103 redundancy?: RedundancyCommand
104 stats?: StatsCommand
105 config?: ConfigCommand
106 socketIO?: SocketIOCommand
107 accounts?: AccountsCommand
108 blocklist?: BlocklistCommand
109 subscriptions?: SubscriptionsCommand
110 live?: LiveCommand
111 services?: ServicesCommand
112 blacklist?: BlacklistCommand
113 captions?: CaptionsCommand
114 changeOwnership?: ChangeOwnershipCommand
115 playlists?: PlaylistsCommand
116 history?: HistoryCommand
117 imports?: ImportsCommand
118 streamingPlaylists?: StreamingPlaylistsCommand
119 channels?: ChannelsCommand
120 comments?: CommentsCommand
121 sql?: SQLCommand
122 notifications?: NotificationsCommand
123 servers?: ServersCommand
124 login?: LoginCommand
125 users?: UsersCommand
126 objectStorage?: ObjectStorageCommand
127 videos?: VideosCommand
128
129 constructor (options: { serverNumber: number } | { url: string }) {
130 if ((options as any).url) {
131 this.setUrl((options as any).url)
132 } else {
133 this.setServerNumber((options as any).serverNumber)
134 }
135
136 this.store = {
137 client: {
138 id: null,
139 secret: null
140 },
141 user: {
142 username: null,
143 password: null
144 }
145 }
146
147 this.assignCommands()
148 }
149
150 setServerNumber (serverNumber: number) {
151 this.serverNumber = serverNumber
152
153 this.parallel = parallelTests()
154
155 this.internalServerNumber = this.parallel ? this.randomServer() : this.serverNumber
156 this.rtmpPort = this.parallel ? this.randomRTMP() : 1936
157 this.rtmpsPort = this.parallel ? this.randomRTMP() : 1937
158 this.port = 9000 + this.internalServerNumber
159
160 this.url = `http://localhost:${this.port}`
161 this.host = `localhost:${this.port}`
162 this.hostname = 'localhost'
163 }
164
165 setUrl (url: string) {
166 const parsed = new URL(url)
167
168 this.url = url
169 this.host = parsed.host
170 this.hostname = parsed.hostname
171 this.port = parseInt(parsed.port)
172 }
173
174 async flushAndRun (configOverride?: Object, options: RunServerOptions = {}) {
175 await ServersCommand.flushTests(this.internalServerNumber)
176
177 return this.run(configOverride, options)
178 }
179
180 async run (configOverrideArg?: any, options: RunServerOptions = {}) {
181 // These actions are async so we need to be sure that they have both been done
182 const serverRunString = {
183 'HTTP server listening': false
184 }
185 const key = 'Database peertube_test' + this.internalServerNumber + ' is ready'
186 serverRunString[key] = false
187
188 const regexps = {
189 client_id: 'Client id: (.+)',
190 client_secret: 'Client secret: (.+)',
191 user_username: 'Username: (.+)',
192 user_password: 'User password: (.+)'
193 }
194
195 await this.assignCustomConfigFile()
196
197 const configOverride = this.buildConfigOverride()
198
199 if (configOverrideArg !== undefined) {
200 Object.assign(configOverride, configOverrideArg)
201 }
202
203 // Share the environment
204 const env = Object.create(process.env)
205 env['NODE_ENV'] = 'test'
206 env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString()
207 env['NODE_CONFIG'] = JSON.stringify(configOverride)
208
209 if (options.env) {
210 Object.assign(env, options.env)
211 }
212
213 const forkOptions = {
214 silent: true,
215 env,
216 detached: true,
217 execArgv: options.nodeArgs || []
218 }
219
220 return new Promise<void>((res, rej) => {
221 const self = this
222 let aggregatedLogs = ''
223
224 this.app = fork(join(root(), 'dist', 'server.js'), options.peertubeArgs || [], forkOptions)
225
226 const onPeerTubeExit = () => rej(new Error('Process exited:\n' + aggregatedLogs))
227 const onParentExit = () => {
228 if (!this.app || !this.app.pid) return
229
230 try {
231 process.kill(self.app.pid)
232 } catch { /* empty */ }
233 }
234
235 this.app.on('exit', onPeerTubeExit)
236 process.on('exit', onParentExit)
237
238 this.app.stdout.on('data', function onStdout (data) {
239 let dontContinue = false
240
241 const log: string = data.toString()
242 aggregatedLogs += log
243
244 // Capture things if we want to
245 for (const key of Object.keys(regexps)) {
246 const regexp = regexps[key]
247 const matches = log.match(regexp)
248 if (matches !== null) {
249 if (key === 'client_id') self.store.client.id = matches[1]
250 else if (key === 'client_secret') self.store.client.secret = matches[1]
251 else if (key === 'user_username') self.store.user.username = matches[1]
252 else if (key === 'user_password') self.store.user.password = matches[1]
253 }
254 }
255
256 // Check if all required sentences are here
257 for (const key of Object.keys(serverRunString)) {
258 if (log.includes(key)) serverRunString[key] = true
259 if (serverRunString[key] === false) dontContinue = true
260 }
261
262 // If no, there is maybe one thing not already initialized (client/user credentials generation...)
263 if (dontContinue === true) return
264
265 if (options.hideLogs === false) {
266 console.log(log)
267 } else {
268 process.removeListener('exit', onParentExit)
269 self.app.stdout.removeListener('data', onStdout)
270 self.app.removeListener('exit', onPeerTubeExit)
271 }
272
273 res()
274 })
275 })
276 }
277
278 async kill () {
279 if (!this.app) return
280
281 await this.sql.cleanup()
282
283 process.kill(-this.app.pid)
284
285 this.app = null
286 }
287
288 private randomServer () {
289 const low = 10
290 const high = 10000
291
292 return randomInt(low, high)
293 }
294
295 private randomRTMP () {
296 const low = 1900
297 const high = 2100
298
299 return randomInt(low, high)
300 }
301
302 private async assignCustomConfigFile () {
303 if (this.internalServerNumber === this.serverNumber) return
304
305 const basePath = join(root(), 'config')
306
307 const tmpConfigFile = join(basePath, `test-${this.internalServerNumber}.yaml`)
308 await copy(join(basePath, `test-${this.serverNumber}.yaml`), tmpConfigFile)
309
310 this.customConfigFile = tmpConfigFile
311 }
312
313 private buildConfigOverride () {
314 if (!this.parallel) return {}
315
316 return {
317 listen: {
318 port: this.port
319 },
320 webserver: {
321 port: this.port
322 },
323 database: {
324 suffix: '_test' + this.internalServerNumber
325 },
326 storage: {
327 tmp: `test${this.internalServerNumber}/tmp/`,
328 bin: `test${this.internalServerNumber}/bin/`,
329 avatars: `test${this.internalServerNumber}/avatars/`,
330 videos: `test${this.internalServerNumber}/videos/`,
331 streaming_playlists: `test${this.internalServerNumber}/streaming-playlists/`,
332 redundancy: `test${this.internalServerNumber}/redundancy/`,
333 logs: `test${this.internalServerNumber}/logs/`,
334 previews: `test${this.internalServerNumber}/previews/`,
335 thumbnails: `test${this.internalServerNumber}/thumbnails/`,
336 torrents: `test${this.internalServerNumber}/torrents/`,
337 captions: `test${this.internalServerNumber}/captions/`,
338 cache: `test${this.internalServerNumber}/cache/`,
339 plugins: `test${this.internalServerNumber}/plugins/`
340 },
341 admin: {
342 email: `admin${this.internalServerNumber}@example.com`
343 },
344 live: {
345 rtmp: {
346 port: this.rtmpPort
347 }
348 }
349 }
350 }
351
352 private assignCommands () {
353 this.bulk = new BulkCommand(this)
354 this.cli = new CLICommand(this)
355 this.customPage = new CustomPagesCommand(this)
356 this.feed = new FeedCommand(this)
357 this.logs = new LogsCommand(this)
358 this.abuses = new AbusesCommand(this)
359 this.overviews = new OverviewsCommand(this)
360 this.search = new SearchCommand(this)
361 this.contactForm = new ContactFormCommand(this)
362 this.debug = new DebugCommand(this)
363 this.follows = new FollowsCommand(this)
364 this.jobs = new JobsCommand(this)
365 this.plugins = new PluginsCommand(this)
366 this.redundancy = new RedundancyCommand(this)
367 this.stats = new StatsCommand(this)
368 this.config = new ConfigCommand(this)
369 this.socketIO = new SocketIOCommand(this)
370 this.accounts = new AccountsCommand(this)
371 this.blocklist = new BlocklistCommand(this)
372 this.subscriptions = new SubscriptionsCommand(this)
373 this.live = new LiveCommand(this)
374 this.services = new ServicesCommand(this)
375 this.blacklist = new BlacklistCommand(this)
376 this.captions = new CaptionsCommand(this)
377 this.changeOwnership = new ChangeOwnershipCommand(this)
378 this.playlists = new PlaylistsCommand(this)
379 this.history = new HistoryCommand(this)
380 this.imports = new ImportsCommand(this)
381 this.streamingPlaylists = new StreamingPlaylistsCommand(this)
382 this.channels = new ChannelsCommand(this)
383 this.comments = new CommentsCommand(this)
384 this.sql = new SQLCommand(this)
385 this.notifications = new NotificationsCommand(this)
386 this.servers = new ServersCommand(this)
387 this.login = new LoginCommand(this)
388 this.users = new UsersCommand(this)
389 this.videos = new VideosCommand(this)
390 this.objectStorage = new ObjectStorageCommand(this)
391 }
392}
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 @@
1import { exec } from 'child_process'
2import { copy, ensureDir, readFile, remove } from 'fs-extra'
3import { basename, join } from 'path'
4import { root } from '@shared/core-utils'
5import { HttpStatusCode } from '@shared/models'
6import { getFileSize, isGithubCI, wait } from '../miscs'
7import { AbstractCommand, OverrideCommandOptions } from '../shared'
8
9export class ServersCommand extends AbstractCommand {
10
11 static flushTests (internalServerNumber: number) {
12 return new Promise<void>((res, rej) => {
13 const suffix = ` -- ${internalServerNumber}`
14
15 return exec('npm run clean:server:test' + suffix, (err, _stdout, stderr) => {
16 if (err || stderr) return rej(err || new Error(stderr))
17
18 return res()
19 })
20 })
21 }
22
23 ping (options: OverrideCommandOptions = {}) {
24 return this.getRequestBody({
25 ...options,
26
27 path: '/api/v1/ping',
28 implicitToken: false,
29 defaultExpectedStatus: HttpStatusCode.OK_200
30 })
31 }
32
33 async cleanupTests () {
34 const p: Promise<any>[] = []
35
36 if (isGithubCI()) {
37 await ensureDir('artifacts')
38
39 const origin = this.buildDirectory('logs/peertube.log')
40 const destname = `peertube-${this.server.internalServerNumber}.log`
41 console.log('Saving logs %s.', destname)
42
43 await copy(origin, join('artifacts', destname))
44 }
45
46 if (this.server.parallel) {
47 p.push(ServersCommand.flushTests(this.server.internalServerNumber))
48 }
49
50 if (this.server.customConfigFile) {
51 p.push(remove(this.server.customConfigFile))
52 }
53
54 return p
55 }
56
57 async waitUntilLog (str: string, count = 1, strictCount = true) {
58 const logfile = this.buildDirectory('logs/peertube.log')
59
60 while (true) {
61 const buf = await readFile(logfile)
62
63 const matches = buf.toString().match(new RegExp(str, 'g'))
64 if (matches && matches.length === count) return
65 if (matches && strictCount === false && matches.length >= count) return
66
67 await wait(1000)
68 }
69 }
70
71 buildDirectory (directory: string) {
72 return join(root(), 'test' + this.server.internalServerNumber, directory)
73 }
74
75 buildWebTorrentFilePath (fileUrl: string) {
76 return this.buildDirectory(join('videos', basename(fileUrl)))
77 }
78
79 buildFragmentedFilePath (videoUUID: string, fileUrl: string) {
80 return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl)))
81 }
82
83 getLogContent () {
84 return readFile(this.buildDirectory('logs/peertube.log'))
85 }
86
87 async getServerFileSize (subPath: string) {
88 const path = this.server.servers.buildDirectory(subPath)
89
90 return getFileSize(path)
91 }
92}
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 @@
1import { ensureDir } from 'fs-extra'
2import { isGithubCI } from '../miscs'
3import { PeerTubeServer, RunServerOptions } from './server'
4
5async function createSingleServer (serverNumber: number, configOverride?: Object, options: RunServerOptions = {}) {
6 const server = new PeerTubeServer({ serverNumber })
7
8 await server.flushAndRun(configOverride, options)
9
10 return server
11}
12
13function createMultipleServers (totalServers: number, configOverride?: Object, options: RunServerOptions = {}) {
14 const serverPromises: Promise<PeerTubeServer>[] = []
15
16 for (let i = 1; i <= totalServers; i++) {
17 serverPromises.push(createSingleServer(i, configOverride, options))
18 }
19
20 return Promise.all(serverPromises)
21}
22
23async function killallServers (servers: PeerTubeServer[]) {
24 return Promise.all(servers.map(s => s.kill()))
25}
26
27async function cleanupTests (servers: PeerTubeServer[]) {
28 await killallServers(servers)
29
30 if (isGithubCI()) {
31 await ensureDir('artifacts')
32 }
33
34 let p: Promise<any>[] = []
35 for (const server of servers) {
36 p = p.concat(server.servers.cleanupTests())
37 }
38
39 return Promise.all(p)
40}
41
42// ---------------------------------------------------------------------------
43
44export {
45 createSingleServer,
46 createMultipleServers,
47 cleanupTests,
48 killallServers
49}
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 @@
1import { HttpStatusCode, ServerStats } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class StatsCommand extends AbstractCommand {
5
6 get (options: OverrideCommandOptions & {
7 useCache?: boolean // default false
8 } = {}) {
9 const { useCache = false } = options
10 const path = '/api/v1/server/stats'
11
12 const query = {
13 t: useCache ? undefined : new Date().getTime()
14 }
15
16 return this.getRequestBody<ServerStats>({
17 ...options,
18
19 path,
20 query,
21 implicitToken: false,
22 defaultExpectedStatus: HttpStatusCode.OK_200
23 })
24 }
25}
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 @@
1import { expect } from 'chai'
2import { sha1 } from '@shared/core-utils/crypto'
3import { makeGetRequest } from '../requests'
4
5async function hlsInfohashExist (serverUrl: string, masterPlaylistUrl: string, fileNumber: number) {
6 const path = '/tracker/announce'
7
8 const infohash = sha1(`2${masterPlaylistUrl}+V${fileNumber}`)
9
10 // From bittorrent-tracker
11 const infohashBinary = escape(Buffer.from(infohash, 'hex').toString('binary')).replace(/[@*/+]/g, function (char) {
12 return '%' + char.charCodeAt(0).toString(16).toUpperCase()
13 })
14
15 const res = await makeGetRequest({
16 url: serverUrl,
17 path,
18 rawQuery: `peer_id=-WW0105-NkvYO/egUAr4&info_hash=${infohashBinary}&port=42100`,
19 expectedStatus: 200
20 })
21
22 expect(res.text).to.not.contain('failure')
23}
24
25export {
26 hlsInfohashExist
27}
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 @@
1import { isAbsolute, join } from 'path'
2import { root } from '../miscs/tests'
3import {
4 makeDeleteRequest,
5 makeGetRequest,
6 makePostBodyRequest,
7 makePutBodyRequest,
8 makeUploadRequest,
9 unwrapBody,
10 unwrapText
11} from '../requests/requests'
12import { PeerTubeServer } from '../server/server'
13
14export interface OverrideCommandOptions {
15 token?: string
16 expectedStatus?: number
17}
18
19interface InternalCommonCommandOptions extends OverrideCommandOptions {
20 // Default to server.url
21 url?: string
22
23 path: string
24 // If we automatically send the server token if the token is not provided
25 implicitToken: boolean
26 defaultExpectedStatus: number
27
28 // Common optional request parameters
29 contentType?: string
30 accept?: string
31 redirects?: number
32 range?: string
33 host?: string
34 headers?: { [ name: string ]: string }
35 requestType?: string
36 xForwardedFor?: string
37}
38
39interface InternalGetCommandOptions extends InternalCommonCommandOptions {
40 query?: { [ id: string ]: any }
41}
42
43interface InternalDeleteCommandOptions extends InternalCommonCommandOptions {
44 query?: { [ id: string ]: any }
45 rawQuery?: string
46}
47
48abstract class AbstractCommand {
49
50 constructor (
51 protected server: PeerTubeServer
52 ) {
53
54 }
55
56 protected getRequestBody <T> (options: InternalGetCommandOptions) {
57 return unwrapBody<T>(this.getRequest(options))
58 }
59
60 protected getRequestText (options: InternalGetCommandOptions) {
61 return unwrapText(this.getRequest(options))
62 }
63
64 protected getRawRequest (options: Omit<InternalGetCommandOptions, 'path'>) {
65 const { url, range } = options
66 const { host, protocol, pathname } = new URL(url)
67
68 return this.getRequest({
69 ...options,
70
71 token: this.buildCommonRequestToken(options),
72 defaultExpectedStatus: this.buildExpectedStatus(options),
73
74 url: `${protocol}//${host}`,
75 path: pathname,
76 range
77 })
78 }
79
80 protected getRequest (options: InternalGetCommandOptions) {
81 const { query } = options
82
83 return makeGetRequest({
84 ...this.buildCommonRequestOptions(options),
85
86 query
87 })
88 }
89
90 protected deleteRequest (options: InternalDeleteCommandOptions) {
91 const { query, rawQuery } = options
92
93 return makeDeleteRequest({
94 ...this.buildCommonRequestOptions(options),
95
96 query,
97 rawQuery
98 })
99 }
100
101 protected putBodyRequest (options: InternalCommonCommandOptions & {
102 fields?: { [ fieldName: string ]: any }
103 }) {
104 const { fields } = options
105
106 return makePutBodyRequest({
107 ...this.buildCommonRequestOptions(options),
108
109 fields
110 })
111 }
112
113 protected postBodyRequest (options: InternalCommonCommandOptions & {
114 fields?: { [ fieldName: string ]: any }
115 }) {
116 const { fields } = options
117
118 return makePostBodyRequest({
119 ...this.buildCommonRequestOptions(options),
120
121 fields
122 })
123 }
124
125 protected postUploadRequest (options: InternalCommonCommandOptions & {
126 fields?: { [ fieldName: string ]: any }
127 attaches?: { [ fieldName: string ]: any }
128 }) {
129 const { fields, attaches } = options
130
131 return makeUploadRequest({
132 ...this.buildCommonRequestOptions(options),
133
134 method: 'POST',
135 fields,
136 attaches
137 })
138 }
139
140 protected putUploadRequest (options: InternalCommonCommandOptions & {
141 fields?: { [ fieldName: string ]: any }
142 attaches?: { [ fieldName: string ]: any }
143 }) {
144 const { fields, attaches } = options
145
146 return makeUploadRequest({
147 ...this.buildCommonRequestOptions(options),
148
149 method: 'PUT',
150 fields,
151 attaches
152 })
153 }
154
155 protected updateImageRequest (options: InternalCommonCommandOptions & {
156 fixture: string
157 fieldname: string
158 }) {
159 const filePath = isAbsolute(options.fixture)
160 ? options.fixture
161 : join(root(), 'server', 'tests', 'fixtures', options.fixture)
162
163 return this.postUploadRequest({
164 ...options,
165
166 fields: {},
167 attaches: { [options.fieldname]: filePath }
168 })
169 }
170
171 protected buildCommonRequestOptions (options: InternalCommonCommandOptions) {
172 const { url, path, redirects, contentType, accept, range, host, headers, requestType, xForwardedFor } = options
173
174 return {
175 url: url ?? this.server.url,
176 path,
177
178 token: this.buildCommonRequestToken(options),
179 expectedStatus: this.buildExpectedStatus(options),
180
181 redirects,
182 contentType,
183 range,
184 host,
185 accept,
186 headers,
187 type: requestType,
188 xForwardedFor
189 }
190 }
191
192 protected buildCommonRequestToken (options: Pick<InternalCommonCommandOptions, 'token' | 'implicitToken'>) {
193 const { token } = options
194
195 const fallbackToken = options.implicitToken
196 ? this.server.accessToken
197 : undefined
198
199 return token !== undefined ? token : fallbackToken
200 }
201
202 protected buildExpectedStatus (options: Pick<InternalCommonCommandOptions, 'expectedStatus' | 'defaultExpectedStatus'>) {
203 const { expectedStatus, defaultExpectedStatus } = options
204
205 return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus
206 }
207}
208
209export {
210 AbstractCommand
211}
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 @@
1import { io } from 'socket.io-client'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class SocketIOCommand extends AbstractCommand {
5
6 getUserNotificationSocket (options: OverrideCommandOptions = {}) {
7 return io(this.server.url + '/user-notifications', {
8 query: { accessToken: options.token ?? this.server.accessToken }
9 })
10 }
11
12 getLiveNotificationSocket () {
13 return io(this.server.url + '/live-videos')
14 }
15}
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 @@
1import { HttpStatusCode, ResultList } from '@shared/models'
2import { Account, ActorFollow } from '../../models/actors'
3import { AccountVideoRate, VideoRateType } from '../../models/videos'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class AccountsCommand extends AbstractCommand {
7
8 list (options: OverrideCommandOptions & {
9 sort?: string // default -createdAt
10 } = {}) {
11 const { sort = '-createdAt' } = options
12 const path = '/api/v1/accounts'
13
14 return this.getRequestBody<ResultList<Account>>({
15 ...options,
16
17 path,
18 query: { sort },
19 implicitToken: false,
20 defaultExpectedStatus: HttpStatusCode.OK_200
21 })
22 }
23
24 get (options: OverrideCommandOptions & {
25 accountName: string
26 }) {
27 const path = '/api/v1/accounts/' + options.accountName
28
29 return this.getRequestBody<Account>({
30 ...options,
31
32 path,
33 implicitToken: false,
34 defaultExpectedStatus: HttpStatusCode.OK_200
35 })
36 }
37
38 listRatings (options: OverrideCommandOptions & {
39 accountName: string
40 rating?: VideoRateType
41 }) {
42 const { rating, accountName } = options
43 const path = '/api/v1/accounts/' + accountName + '/ratings'
44
45 const query = { rating }
46
47 return this.getRequestBody<ResultList<AccountVideoRate>>({
48 ...options,
49
50 path,
51 query,
52 implicitToken: true,
53 defaultExpectedStatus: HttpStatusCode.OK_200
54 })
55 }
56
57 listFollowers (options: OverrideCommandOptions & {
58 accountName: string
59 start?: number
60 count?: number
61 sort?: string
62 search?: string
63 }) {
64 const { accountName, start, count, sort, search } = options
65 const path = '/api/v1/accounts/' + accountName + '/followers'
66
67 const query = { start, count, sort, search }
68
69 return this.getRequestBody<ResultList<ActorFollow>>({
70 ...options,
71
72 path,
73 query,
74 implicitToken: true,
75 defaultExpectedStatus: HttpStatusCode.OK_200
76 })
77 }
78}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { pathExists, readdir } from 'fs-extra'
5import { join } from 'path'
6import { root } from '@shared/core-utils'
7import { Account, VideoChannel } from '@shared/models'
8import { PeerTubeServer } from '../server'
9
10async function expectChannelsFollows (options: {
11 server: PeerTubeServer
12 handle: string
13 followers: number
14 following: number
15}) {
16 const { server } = options
17 const { data } = await server.channels.list()
18
19 return expectActorFollow({ ...options, data })
20}
21
22async function expectAccountFollows (options: {
23 server: PeerTubeServer
24 handle: string
25 followers: number
26 following: number
27}) {
28 const { server } = options
29 const { data } = await server.accounts.list()
30
31 return expectActorFollow({ ...options, data })
32}
33
34async function checkActorFilesWereRemoved (filename: string, serverNumber: number) {
35 const testDirectory = 'test' + serverNumber
36
37 for (const directory of [ 'avatars' ]) {
38 const directoryPath = join(root(), testDirectory, directory)
39
40 const directoryExists = await pathExists(directoryPath)
41 expect(directoryExists).to.be.true
42
43 const files = await readdir(directoryPath)
44 for (const file of files) {
45 expect(file).to.not.contain(filename)
46 }
47 }
48}
49
50export {
51 expectAccountFollows,
52 expectChannelsFollows,
53 checkActorFilesWereRemoved
54}
55
56// ---------------------------------------------------------------------------
57
58function expectActorFollow (options: {
59 server: PeerTubeServer
60 data: (Account | VideoChannel)[]
61 handle: string
62 followers: number
63 following: number
64}) {
65 const { server, data, handle, followers, following } = options
66
67 const actor = data.find(a => a.name + '@' + a.host === handle)
68 const message = `${handle} on ${server.url}`
69
70 expect(actor, message).to.exist
71 expect(actor.followersCount).to.equal(followers, message)
72 expect(actor.followingCount).to.equal(following, message)
73}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@shared/models'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6type ListBlocklistOptions = OverrideCommandOptions & {
7 start: number
8 count: number
9 sort: string // default -createdAt
10}
11
12export class BlocklistCommand extends AbstractCommand {
13
14 listMyAccountBlocklist (options: ListBlocklistOptions) {
15 const path = '/api/v1/users/me/blocklist/accounts'
16
17 return this.listBlocklist<AccountBlock>(options, path)
18 }
19
20 listMyServerBlocklist (options: ListBlocklistOptions) {
21 const path = '/api/v1/users/me/blocklist/servers'
22
23 return this.listBlocklist<ServerBlock>(options, path)
24 }
25
26 listServerAccountBlocklist (options: ListBlocklistOptions) {
27 const path = '/api/v1/server/blocklist/accounts'
28
29 return this.listBlocklist<AccountBlock>(options, path)
30 }
31
32 listServerServerBlocklist (options: ListBlocklistOptions) {
33 const path = '/api/v1/server/blocklist/servers'
34
35 return this.listBlocklist<ServerBlock>(options, path)
36 }
37
38 // ---------------------------------------------------------------------------
39
40 getStatus (options: OverrideCommandOptions & {
41 accounts?: string[]
42 hosts?: string[]
43 }) {
44 const { accounts, hosts } = options
45
46 const path = '/api/v1/blocklist/status'
47
48 return this.getRequestBody<BlockStatus>({
49 ...options,
50
51 path,
52 query: {
53 accounts,
54 hosts
55 },
56 implicitToken: false,
57 defaultExpectedStatus: HttpStatusCode.OK_200
58 })
59 }
60
61 // ---------------------------------------------------------------------------
62
63 addToMyBlocklist (options: OverrideCommandOptions & {
64 account?: string
65 server?: string
66 }) {
67 const { account, server } = options
68
69 const path = account
70 ? '/api/v1/users/me/blocklist/accounts'
71 : '/api/v1/users/me/blocklist/servers'
72
73 return this.postBodyRequest({
74 ...options,
75
76 path,
77 fields: {
78 accountName: account,
79 host: server
80 },
81 implicitToken: true,
82 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
83 })
84 }
85
86 addToServerBlocklist (options: OverrideCommandOptions & {
87 account?: string
88 server?: string
89 }) {
90 const { account, server } = options
91
92 const path = account
93 ? '/api/v1/server/blocklist/accounts'
94 : '/api/v1/server/blocklist/servers'
95
96 return this.postBodyRequest({
97 ...options,
98
99 path,
100 fields: {
101 accountName: account,
102 host: server
103 },
104 implicitToken: true,
105 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
106 })
107 }
108
109 // ---------------------------------------------------------------------------
110
111 removeFromMyBlocklist (options: OverrideCommandOptions & {
112 account?: string
113 server?: string
114 }) {
115 const { account, server } = options
116
117 const path = account
118 ? '/api/v1/users/me/blocklist/accounts/' + account
119 : '/api/v1/users/me/blocklist/servers/' + server
120
121 return this.deleteRequest({
122 ...options,
123
124 path,
125 implicitToken: true,
126 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
127 })
128 }
129
130 removeFromServerBlocklist (options: OverrideCommandOptions & {
131 account?: string
132 server?: string
133 }) {
134 const { account, server } = options
135
136 const path = account
137 ? '/api/v1/server/blocklist/accounts/' + account
138 : '/api/v1/server/blocklist/servers/' + server
139
140 return this.deleteRequest({
141 ...options,
142
143 path,
144 implicitToken: true,
145 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
146 })
147 }
148
149 private listBlocklist <T> (options: ListBlocklistOptions, path: string) {
150 const { start, count, sort = '-createdAt' } = options
151
152 return this.getRequestBody<ResultList<T>>({
153 ...options,
154
155 path,
156 query: { start, count, sort },
157 implicitToken: true,
158 defaultExpectedStatus: HttpStatusCode.OK_200
159 })
160 }
161
162}
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 @@
1export * from './accounts-command'
2export * from './actors'
3export * from './blocklist-command'
4export * from './login'
5export * from './login-command'
6export * from './notifications'
7export * from './notifications-command'
8export * from './subscriptions-command'
9export * 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 @@
1import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
2import { unwrapBody } from '../requests'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class LoginCommand extends AbstractCommand {
6
7 login (options: OverrideCommandOptions & {
8 client?: { id?: string, secret?: string }
9 user?: { username: string, password?: string }
10 } = {}) {
11 const { client = this.server.store.client, user = this.server.store.user } = options
12 const path = '/api/v1/users/token'
13
14 const body = {
15 client_id: client.id,
16 client_secret: client.secret,
17 username: user.username,
18 password: user.password ?? 'password',
19 response_type: 'code',
20 grant_type: 'password',
21 scope: 'upload'
22 }
23
24 return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({
25 ...options,
26
27 path,
28 requestType: 'form',
29 fields: body,
30 implicitToken: false,
31 defaultExpectedStatus: HttpStatusCode.OK_200
32 }))
33 }
34
35 getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
36 getAccessToken (arg1: string, password?: string): Promise<string>
37 async getAccessToken (arg1?: { username: string, password?: string } | string, password?: string) {
38 let user: { username: string, password?: string }
39
40 if (!arg1) user = this.server.store.user
41 else if (typeof arg1 === 'object') user = arg1
42 else user = { username: arg1, password }
43
44 try {
45 const body = await this.login({ user })
46
47 return body.access_token
48 } catch (err) {
49 throw new Error(`Cannot authenticate. Please check your username/password. (${err})`)
50 }
51 }
52
53 loginUsingExternalToken (options: OverrideCommandOptions & {
54 username: string
55 externalAuthToken: string
56 }) {
57 const { username, externalAuthToken } = options
58 const path = '/api/v1/users/token'
59
60 const body = {
61 client_id: this.server.store.client.id,
62 client_secret: this.server.store.client.secret,
63 username: username,
64 response_type: 'code',
65 grant_type: 'password',
66 scope: 'upload',
67 externalAuthToken
68 }
69
70 return this.postBodyRequest({
71 ...options,
72
73 path,
74 requestType: 'form',
75 fields: body,
76 implicitToken: false,
77 defaultExpectedStatus: HttpStatusCode.OK_200
78 })
79 }
80
81 logout (options: OverrideCommandOptions & {
82 token: string
83 }) {
84 const path = '/api/v1/users/revoke-token'
85
86 return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({
87 ...options,
88
89 path,
90 requestType: 'form',
91 implicitToken: false,
92 defaultExpectedStatus: HttpStatusCode.OK_200
93 }))
94 }
95
96 refreshToken (options: OverrideCommandOptions & {
97 refreshToken: string
98 }) {
99 const path = '/api/v1/users/token'
100
101 const body = {
102 client_id: this.server.store.client.id,
103 client_secret: this.server.store.client.secret,
104 refresh_token: options.refreshToken,
105 response_type: 'code',
106 grant_type: 'refresh_token'
107 }
108
109 return this.postBodyRequest({
110 ...options,
111
112 path,
113 requestType: 'form',
114 fields: body,
115 implicitToken: false,
116 defaultExpectedStatus: HttpStatusCode.OK_200
117 })
118 }
119
120 getClient (options: OverrideCommandOptions = {}) {
121 const path = '/api/v1/oauth-clients/local'
122
123 return this.getRequestBody<{ client_id: string, client_secret: string }>({
124 ...options,
125
126 path,
127 host: this.server.host,
128 implicitToken: false,
129 defaultExpectedStatus: HttpStatusCode.OK_200
130 })
131 }
132}
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 @@
1import { PeerTubeServer } from '../server/server'
2
3function setAccessTokensToServers (servers: PeerTubeServer[]) {
4 const tasks: Promise<any>[] = []
5
6 for (const server of servers) {
7 const p = server.login.getAccessToken()
8 .then(t => { server.accessToken = t })
9 tasks.push(p)
10 }
11
12 return Promise.all(tasks)
13}
14
15// ---------------------------------------------------------------------------
16
17export {
18 setAccessTokensToServers
19}
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 @@
1import { HttpStatusCode, ResultList } from '@shared/models'
2import { UserNotification, UserNotificationSetting } from '../../models/users'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class NotificationsCommand extends AbstractCommand {
6
7 updateMySettings (options: OverrideCommandOptions & {
8 settings: UserNotificationSetting
9 }) {
10 const path = '/api/v1/users/me/notification-settings'
11
12 return this.putBodyRequest({
13 ...options,
14
15 path,
16 fields: options.settings,
17 implicitToken: true,
18 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
19 })
20 }
21
22 list (options: OverrideCommandOptions & {
23 start?: number
24 count?: number
25 unread?: boolean
26 sort?: string
27 }) {
28 const { start, count, unread, sort = '-createdAt' } = options
29 const path = '/api/v1/users/me/notifications'
30
31 return this.getRequestBody<ResultList<UserNotification>>({
32 ...options,
33
34 path,
35 query: {
36 start,
37 count,
38 sort,
39 unread
40 },
41 implicitToken: true,
42 defaultExpectedStatus: HttpStatusCode.OK_200
43 })
44 }
45
46 markAsRead (options: OverrideCommandOptions & {
47 ids: number[]
48 }) {
49 const { ids } = options
50 const path = '/api/v1/users/me/notifications/read'
51
52 return this.postBodyRequest({
53 ...options,
54
55 path,
56 fields: { ids },
57 implicitToken: true,
58 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
59 })
60 }
61
62 markAsReadAll (options: OverrideCommandOptions) {
63 const path = '/api/v1/users/me/notifications/read-all'
64
65 return this.postBodyRequest({
66 ...options,
67
68 path,
69 implicitToken: true,
70 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
71 })
72 }
73
74 async getLatest (options: OverrideCommandOptions = {}) {
75 const { total, data } = await this.list({
76 ...options,
77 start: 0,
78 count: 1,
79 sort: '-createdAt'
80 })
81
82 if (total === 0) return undefined
83
84 return data[0]
85 }
86}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { inspect } from 'util'
5import { AbuseState, PluginType } from '@shared/models'
6import { UserNotification, UserNotificationSetting, UserNotificationSettingValue, UserNotificationType } from '../../models/users'
7import { MockSmtpServer } from '../mock-servers/mock-email'
8import { PeerTubeServer } from '../server'
9import { doubleFollow } from '../server/follows'
10import { createMultipleServers } from '../server/servers'
11import { setAccessTokensToServers } from './login'
12
13type CheckerBaseParams = {
14 server: PeerTubeServer
15 emails: any[]
16 socketNotifications: UserNotification[]
17 token: string
18 check?: { web: boolean, mail: boolean }
19}
20
21type CheckerType = 'presence' | 'absence'
22
23function getAllNotificationsSettings (): UserNotificationSetting {
24 return {
25 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
26 newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
27 abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
28 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
29 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
30 myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
31 myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
32 commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
33 newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
34 newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
35 newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
36 abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
37 abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
38 autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
39 newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
40 newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
41 }
42}
43
44async function checkNewVideoFromSubscription (options: CheckerBaseParams & {
45 videoName: string
46 shortUUID: string
47 checkType: CheckerType
48}) {
49 const { videoName, shortUUID } = options
50 const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION
51
52 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
53 if (checkType === 'presence') {
54 expect(notification).to.not.be.undefined
55 expect(notification.type).to.equal(notificationType)
56
57 checkVideo(notification.video, videoName, shortUUID)
58 checkActor(notification.video.channel)
59 } else {
60 expect(notification).to.satisfy((n: UserNotification) => {
61 return n === undefined || n.type !== UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION || n.video.name !== videoName
62 })
63 }
64 }
65
66 function emailNotificationFinder (email: object) {
67 const text = email['text']
68 return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1
69 }
70
71 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
72}
73
74async function checkVideoIsPublished (options: CheckerBaseParams & {
75 videoName: string
76 shortUUID: string
77 checkType: CheckerType
78}) {
79 const { videoName, shortUUID } = options
80 const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED
81
82 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
83 if (checkType === 'presence') {
84 expect(notification).to.not.be.undefined
85 expect(notification.type).to.equal(notificationType)
86
87 checkVideo(notification.video, videoName, shortUUID)
88 checkActor(notification.video.channel)
89 } else {
90 expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
91 }
92 }
93
94 function emailNotificationFinder (email: object) {
95 const text: string = email['text']
96 return text.includes(shortUUID) && text.includes('Your video')
97 }
98
99 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
100}
101
102async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
103 videoName: string
104 shortUUID: string
105 url: string
106 success: boolean
107 checkType: CheckerType
108}) {
109 const { videoName, shortUUID, url, success } = options
110
111 const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR
112
113 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
114 if (checkType === 'presence') {
115 expect(notification).to.not.be.undefined
116 expect(notification.type).to.equal(notificationType)
117
118 expect(notification.videoImport.targetUrl).to.equal(url)
119
120 if (success) checkVideo(notification.videoImport.video, videoName, shortUUID)
121 } else {
122 expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url)
123 }
124 }
125
126 function emailNotificationFinder (email: object) {
127 const text: string = email['text']
128 const toFind = success ? ' finished' : ' error'
129
130 return text.includes(url) && text.includes(toFind)
131 }
132
133 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
134}
135
136async function checkUserRegistered (options: CheckerBaseParams & {
137 username: string
138 checkType: CheckerType
139}) {
140 const { username } = options
141 const notificationType = UserNotificationType.NEW_USER_REGISTRATION
142
143 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
144 if (checkType === 'presence') {
145 expect(notification).to.not.be.undefined
146 expect(notification.type).to.equal(notificationType)
147
148 checkActor(notification.account)
149 expect(notification.account.name).to.equal(username)
150 } else {
151 expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username)
152 }
153 }
154
155 function emailNotificationFinder (email: object) {
156 const text: string = email['text']
157
158 return text.includes(' registered.') && text.includes(username)
159 }
160
161 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
162}
163
164async function checkNewActorFollow (options: CheckerBaseParams & {
165 followType: 'channel' | 'account'
166 followerName: string
167 followerDisplayName: string
168 followingDisplayName: string
169 checkType: CheckerType
170}) {
171 const { followType, followerName, followerDisplayName, followingDisplayName } = options
172 const notificationType = UserNotificationType.NEW_FOLLOW
173
174 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
175 if (checkType === 'presence') {
176 expect(notification).to.not.be.undefined
177 expect(notification.type).to.equal(notificationType)
178
179 checkActor(notification.actorFollow.follower)
180 expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName)
181 expect(notification.actorFollow.follower.name).to.equal(followerName)
182 expect(notification.actorFollow.follower.host).to.not.be.undefined
183
184 const following = notification.actorFollow.following
185 expect(following.displayName).to.equal(followingDisplayName)
186 expect(following.type).to.equal(followType)
187 } else {
188 expect(notification).to.satisfy(n => {
189 return n.type !== notificationType ||
190 (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName)
191 })
192 }
193 }
194
195 function emailNotificationFinder (email: object) {
196 const text: string = email['text']
197
198 return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName)
199 }
200
201 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
202}
203
204async function checkNewInstanceFollower (options: CheckerBaseParams & {
205 followerHost: string
206 checkType: CheckerType
207}) {
208 const { followerHost } = options
209 const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER
210
211 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
212 if (checkType === 'presence') {
213 expect(notification).to.not.be.undefined
214 expect(notification.type).to.equal(notificationType)
215
216 checkActor(notification.actorFollow.follower)
217 expect(notification.actorFollow.follower.name).to.equal('peertube')
218 expect(notification.actorFollow.follower.host).to.equal(followerHost)
219
220 expect(notification.actorFollow.following.name).to.equal('peertube')
221 } else {
222 expect(notification).to.satisfy(n => {
223 return n.type !== notificationType || n.actorFollow.follower.host !== followerHost
224 })
225 }
226 }
227
228 function emailNotificationFinder (email: object) {
229 const text: string = email['text']
230
231 return text.includes('instance has a new follower') && text.includes(followerHost)
232 }
233
234 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
235}
236
237async function checkAutoInstanceFollowing (options: CheckerBaseParams & {
238 followerHost: string
239 followingHost: string
240 checkType: CheckerType
241}) {
242 const { followerHost, followingHost } = options
243 const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING
244
245 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
246 if (checkType === 'presence') {
247 expect(notification).to.not.be.undefined
248 expect(notification.type).to.equal(notificationType)
249
250 const following = notification.actorFollow.following
251 checkActor(following)
252 expect(following.name).to.equal('peertube')
253 expect(following.host).to.equal(followingHost)
254
255 expect(notification.actorFollow.follower.name).to.equal('peertube')
256 expect(notification.actorFollow.follower.host).to.equal(followerHost)
257 } else {
258 expect(notification).to.satisfy(n => {
259 return n.type !== notificationType || n.actorFollow.following.host !== followingHost
260 })
261 }
262 }
263
264 function emailNotificationFinder (email: object) {
265 const text: string = email['text']
266
267 return text.includes(' automatically followed a new instance') && text.includes(followingHost)
268 }
269
270 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
271}
272
273async function checkCommentMention (options: CheckerBaseParams & {
274 shortUUID: string
275 commentId: number
276 threadId: number
277 byAccountDisplayName: string
278 checkType: CheckerType
279}) {
280 const { shortUUID, commentId, threadId, byAccountDisplayName } = options
281 const notificationType = UserNotificationType.COMMENT_MENTION
282
283 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
284 if (checkType === 'presence') {
285 expect(notification).to.not.be.undefined
286 expect(notification.type).to.equal(notificationType)
287
288 checkComment(notification.comment, commentId, threadId)
289 checkActor(notification.comment.account)
290 expect(notification.comment.account.displayName).to.equal(byAccountDisplayName)
291
292 checkVideo(notification.comment.video, undefined, shortUUID)
293 } else {
294 expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId)
295 }
296 }
297
298 function emailNotificationFinder (email: object) {
299 const text: string = email['text']
300
301 return text.includes(' mentioned ') && text.includes(shortUUID) && text.includes(byAccountDisplayName)
302 }
303
304 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
305}
306
307let lastEmailCount = 0
308
309async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
310 shortUUID: string
311 commentId: number
312 threadId: number
313 checkType: CheckerType
314}) {
315 const { server, shortUUID, commentId, threadId, checkType, emails } = options
316 const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
317
318 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
319 if (checkType === 'presence') {
320 expect(notification).to.not.be.undefined
321 expect(notification.type).to.equal(notificationType)
322
323 checkComment(notification.comment, commentId, threadId)
324 checkActor(notification.comment.account)
325 checkVideo(notification.comment.video, undefined, shortUUID)
326 } else {
327 expect(notification).to.satisfy((n: UserNotification) => {
328 return n === undefined || n.comment === undefined || n.comment.id !== commentId
329 })
330 }
331 }
332
333 const commentUrl = `http://localhost:${server.port}/w/${shortUUID};threadId=${threadId}`
334
335 function emailNotificationFinder (email: object) {
336 return email['text'].indexOf(commentUrl) !== -1
337 }
338
339 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
340
341 if (checkType === 'presence') {
342 // We cannot detect email duplicates, so check we received another email
343 expect(emails).to.have.length.above(lastEmailCount)
344 lastEmailCount = emails.length
345 }
346}
347
348async function checkNewVideoAbuseForModerators (options: CheckerBaseParams & {
349 shortUUID: string
350 videoName: string
351 checkType: CheckerType
352}) {
353 const { shortUUID, videoName } = options
354 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
355
356 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
357 if (checkType === 'presence') {
358 expect(notification).to.not.be.undefined
359 expect(notification.type).to.equal(notificationType)
360
361 expect(notification.abuse.id).to.be.a('number')
362 checkVideo(notification.abuse.video, videoName, shortUUID)
363 } else {
364 expect(notification).to.satisfy((n: UserNotification) => {
365 return n === undefined || n.abuse === undefined || n.abuse.video.shortUUID !== shortUUID
366 })
367 }
368 }
369
370 function emailNotificationFinder (email: object) {
371 const text = email['text']
372 return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1
373 }
374
375 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
376}
377
378async function checkNewAbuseMessage (options: CheckerBaseParams & {
379 abuseId: number
380 message: string
381 toEmail: string
382 checkType: CheckerType
383}) {
384 const { abuseId, message, toEmail } = options
385 const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE
386
387 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
388 if (checkType === 'presence') {
389 expect(notification).to.not.be.undefined
390 expect(notification.type).to.equal(notificationType)
391
392 expect(notification.abuse.id).to.equal(abuseId)
393 } else {
394 expect(notification).to.satisfy((n: UserNotification) => {
395 return n === undefined || n.type !== notificationType || n.abuse === undefined || n.abuse.id !== abuseId
396 })
397 }
398 }
399
400 function emailNotificationFinder (email: object) {
401 const text = email['text']
402 const to = email['to'].filter(t => t.address === toEmail)
403
404 return text.indexOf(message) !== -1 && to.length !== 0
405 }
406
407 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
408}
409
410async function checkAbuseStateChange (options: CheckerBaseParams & {
411 abuseId: number
412 state: AbuseState
413 checkType: CheckerType
414}) {
415 const { abuseId, state } = options
416 const notificationType = UserNotificationType.ABUSE_STATE_CHANGE
417
418 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
419 if (checkType === 'presence') {
420 expect(notification).to.not.be.undefined
421 expect(notification.type).to.equal(notificationType)
422
423 expect(notification.abuse.id).to.equal(abuseId)
424 expect(notification.abuse.state).to.equal(state)
425 } else {
426 expect(notification).to.satisfy((n: UserNotification) => {
427 return n === undefined || n.abuse === undefined || n.abuse.id !== abuseId
428 })
429 }
430 }
431
432 function emailNotificationFinder (email: object) {
433 const text = email['text']
434
435 const contains = state === AbuseState.ACCEPTED
436 ? ' accepted'
437 : ' rejected'
438
439 return text.indexOf(contains) !== -1
440 }
441
442 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
443}
444
445async function checkNewCommentAbuseForModerators (options: CheckerBaseParams & {
446 shortUUID: string
447 videoName: string
448 checkType: CheckerType
449}) {
450 const { shortUUID, videoName } = options
451 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
452
453 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
454 if (checkType === 'presence') {
455 expect(notification).to.not.be.undefined
456 expect(notification.type).to.equal(notificationType)
457
458 expect(notification.abuse.id).to.be.a('number')
459 checkVideo(notification.abuse.comment.video, videoName, shortUUID)
460 } else {
461 expect(notification).to.satisfy((n: UserNotification) => {
462 return n === undefined || n.abuse === undefined || n.abuse.comment.video.shortUUID !== shortUUID
463 })
464 }
465 }
466
467 function emailNotificationFinder (email: object) {
468 const text = email['text']
469 return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1
470 }
471
472 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
473}
474
475async function checkNewAccountAbuseForModerators (options: CheckerBaseParams & {
476 displayName: string
477 checkType: CheckerType
478}) {
479 const { displayName } = options
480 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
481
482 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
483 if (checkType === 'presence') {
484 expect(notification).to.not.be.undefined
485 expect(notification.type).to.equal(notificationType)
486
487 expect(notification.abuse.id).to.be.a('number')
488 expect(notification.abuse.account.displayName).to.equal(displayName)
489 } else {
490 expect(notification).to.satisfy((n: UserNotification) => {
491 return n === undefined || n.abuse === undefined || n.abuse.account.displayName !== displayName
492 })
493 }
494 }
495
496 function emailNotificationFinder (email: object) {
497 const text = email['text']
498 return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1
499 }
500
501 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
502}
503
504async function checkVideoAutoBlacklistForModerators (options: CheckerBaseParams & {
505 shortUUID: string
506 videoName: string
507 checkType: CheckerType
508}) {
509 const { shortUUID, videoName } = options
510 const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS
511
512 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
513 if (checkType === 'presence') {
514 expect(notification).to.not.be.undefined
515 expect(notification.type).to.equal(notificationType)
516
517 expect(notification.videoBlacklist.video.id).to.be.a('number')
518 checkVideo(notification.videoBlacklist.video, videoName, shortUUID)
519 } else {
520 expect(notification).to.satisfy((n: UserNotification) => {
521 return n === undefined || n.video === undefined || n.video.shortUUID !== shortUUID
522 })
523 }
524 }
525
526 function emailNotificationFinder (email: object) {
527 const text = email['text']
528 return text.indexOf(shortUUID) !== -1 && email['text'].indexOf('video-auto-blacklist/list') !== -1
529 }
530
531 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
532}
533
534async function checkNewBlacklistOnMyVideo (options: CheckerBaseParams & {
535 shortUUID: string
536 videoName: string
537 blacklistType: 'blacklist' | 'unblacklist'
538}) {
539 const { videoName, shortUUID, blacklistType } = options
540 const notificationType = blacklistType === 'blacklist'
541 ? UserNotificationType.BLACKLIST_ON_MY_VIDEO
542 : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO
543
544 function notificationChecker (notification: UserNotification) {
545 expect(notification).to.not.be.undefined
546 expect(notification.type).to.equal(notificationType)
547
548 const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video
549
550 checkVideo(video, videoName, shortUUID)
551 }
552
553 function emailNotificationFinder (email: object) {
554 const text = email['text']
555 const blacklistText = blacklistType === 'blacklist'
556 ? 'blacklisted'
557 : 'unblacklisted'
558
559 return text.includes(shortUUID) && text.includes(blacklistText)
560 }
561
562 await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' })
563}
564
565async function checkNewPeerTubeVersion (options: CheckerBaseParams & {
566 latestVersion: string
567 checkType: CheckerType
568}) {
569 const { latestVersion } = options
570 const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION
571
572 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
573 if (checkType === 'presence') {
574 expect(notification).to.not.be.undefined
575 expect(notification.type).to.equal(notificationType)
576
577 expect(notification.peertube).to.exist
578 expect(notification.peertube.latestVersion).to.equal(latestVersion)
579 } else {
580 expect(notification).to.satisfy((n: UserNotification) => {
581 return n === undefined || n.peertube === undefined || n.peertube.latestVersion !== latestVersion
582 })
583 }
584 }
585
586 function emailNotificationFinder (email: object) {
587 const text = email['text']
588
589 return text.includes(latestVersion)
590 }
591
592 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
593}
594
595async function checkNewPluginVersion (options: CheckerBaseParams & {
596 pluginType: PluginType
597 pluginName: string
598 checkType: CheckerType
599}) {
600 const { pluginName, pluginType } = options
601 const notificationType = UserNotificationType.NEW_PLUGIN_VERSION
602
603 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
604 if (checkType === 'presence') {
605 expect(notification).to.not.be.undefined
606 expect(notification.type).to.equal(notificationType)
607
608 expect(notification.plugin.name).to.equal(pluginName)
609 expect(notification.plugin.type).to.equal(pluginType)
610 } else {
611 expect(notification).to.satisfy((n: UserNotification) => {
612 return n === undefined || n.plugin === undefined || n.plugin.name !== pluginName
613 })
614 }
615 }
616
617 function emailNotificationFinder (email: object) {
618 const text = email['text']
619
620 return text.includes(pluginName)
621 }
622
623 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
624}
625
626async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: any = {}) {
627 const userNotifications: UserNotification[] = []
628 const adminNotifications: UserNotification[] = []
629 const adminNotificationsServer2: UserNotification[] = []
630 const emails: object[] = []
631
632 const port = await MockSmtpServer.Instance.collectEmails(emails)
633
634 const overrideConfig = {
635 smtp: {
636 hostname: 'localhost',
637 port
638 },
639 signup: {
640 limit: 20
641 }
642 }
643 const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg))
644
645 await setAccessTokensToServers(servers)
646
647 if (serversCount > 1) {
648 await doubleFollow(servers[0], servers[1])
649 }
650
651 const user = { username: 'user_1', password: 'super password' }
652 await servers[0].users.create({ ...user, videoQuota: 10 * 1000 * 1000 })
653 const userAccessToken = await servers[0].login.getAccessToken(user)
654
655 await servers[0].notifications.updateMySettings({ token: userAccessToken, settings: getAllNotificationsSettings() })
656 await servers[0].notifications.updateMySettings({ settings: getAllNotificationsSettings() })
657
658 if (serversCount > 1) {
659 await servers[1].notifications.updateMySettings({ settings: getAllNotificationsSettings() })
660 }
661
662 {
663 const socket = servers[0].socketIO.getUserNotificationSocket({ token: userAccessToken })
664 socket.on('new-notification', n => userNotifications.push(n))
665 }
666 {
667 const socket = servers[0].socketIO.getUserNotificationSocket()
668 socket.on('new-notification', n => adminNotifications.push(n))
669 }
670
671 if (serversCount > 1) {
672 const socket = servers[1].socketIO.getUserNotificationSocket()
673 socket.on('new-notification', n => adminNotificationsServer2.push(n))
674 }
675
676 const { videoChannels } = await servers[0].users.getMyInfo()
677 const channelId = videoChannels[0].id
678
679 return {
680 userNotifications,
681 adminNotifications,
682 adminNotificationsServer2,
683 userAccessToken,
684 emails,
685 servers,
686 channelId
687 }
688}
689
690// ---------------------------------------------------------------------------
691
692export {
693 getAllNotificationsSettings,
694
695 CheckerBaseParams,
696 CheckerType,
697 checkMyVideoImportIsFinished,
698 checkUserRegistered,
699 checkAutoInstanceFollowing,
700 checkVideoIsPublished,
701 checkNewVideoFromSubscription,
702 checkNewActorFollow,
703 checkNewCommentOnMyVideo,
704 checkNewBlacklistOnMyVideo,
705 checkCommentMention,
706 checkNewVideoAbuseForModerators,
707 checkVideoAutoBlacklistForModerators,
708 checkNewAbuseMessage,
709 checkAbuseStateChange,
710 checkNewInstanceFollower,
711 prepareNotificationsTest,
712 checkNewCommentAbuseForModerators,
713 checkNewAccountAbuseForModerators,
714 checkNewPeerTubeVersion,
715 checkNewPluginVersion
716}
717
718// ---------------------------------------------------------------------------
719
720async function checkNotification (options: CheckerBaseParams & {
721 notificationChecker: (notification: UserNotification, checkType: CheckerType) => void
722 emailNotificationFinder: (email: object) => boolean
723 checkType: CheckerType
724}) {
725 const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options
726
727 const check = options.check || { web: true, mail: true }
728
729 if (check.web) {
730 const notification = await server.notifications.getLatest({ token: token })
731
732 if (notification || checkType !== 'absence') {
733 notificationChecker(notification, checkType)
734 }
735
736 const socketNotification = socketNotifications.find(n => {
737 try {
738 notificationChecker(n, 'presence')
739 return true
740 } catch {
741 return false
742 }
743 })
744
745 if (checkType === 'presence') {
746 const obj = inspect(socketNotifications, { depth: 5 })
747 expect(socketNotification, 'The socket notification is absent when it should be present. ' + obj).to.not.be.undefined
748 } else {
749 const obj = inspect(socketNotification, { depth: 5 })
750 expect(socketNotification, 'The socket notification is present when it should not be present. ' + obj).to.be.undefined
751 }
752 }
753
754 if (check.mail) {
755 // Last email
756 const email = emails
757 .slice()
758 .reverse()
759 .find(e => emailNotificationFinder(e))
760
761 if (checkType === 'presence') {
762 const texts = emails.map(e => e.text)
763 expect(email, 'The email is absent when is should be present. ' + inspect(texts)).to.not.be.undefined
764 } else {
765 expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined
766 }
767 }
768}
769
770function checkVideo (video: any, videoName?: string, shortUUID?: string) {
771 if (videoName) {
772 expect(video.name).to.be.a('string')
773 expect(video.name).to.not.be.empty
774 expect(video.name).to.equal(videoName)
775 }
776
777 if (shortUUID) {
778 expect(video.shortUUID).to.be.a('string')
779 expect(video.shortUUID).to.not.be.empty
780 expect(video.shortUUID).to.equal(shortUUID)
781 }
782
783 expect(video.id).to.be.a('number')
784}
785
786function checkActor (actor: any) {
787 expect(actor.displayName).to.be.a('string')
788 expect(actor.displayName).to.not.be.empty
789 expect(actor.host).to.not.be.undefined
790}
791
792function checkComment (comment: any, commentId: number, threadId: number) {
793 expect(comment.id).to.equal(commentId)
794 expect(comment.threadId).to.equal(threadId)
795}
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 @@
1import { HttpStatusCode, ResultList, Video, VideoChannel } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class SubscriptionsCommand extends AbstractCommand {
5
6 add (options: OverrideCommandOptions & {
7 targetUri: string
8 }) {
9 const path = '/api/v1/users/me/subscriptions'
10
11 return this.postBodyRequest({
12 ...options,
13
14 path,
15 fields: { uri: options.targetUri },
16 implicitToken: true,
17 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
18 })
19 }
20
21 list (options: OverrideCommandOptions & {
22 sort?: string // default -createdAt
23 search?: string
24 } = {}) {
25 const { sort = '-createdAt', search } = options
26 const path = '/api/v1/users/me/subscriptions'
27
28 return this.getRequestBody<ResultList<VideoChannel>>({
29 ...options,
30
31 path,
32 query: {
33 sort,
34 search
35 },
36 implicitToken: true,
37 defaultExpectedStatus: HttpStatusCode.OK_200
38 })
39 }
40
41 listVideos (options: OverrideCommandOptions & {
42 sort?: string // default -createdAt
43 } = {}) {
44 const { sort = '-createdAt' } = options
45 const path = '/api/v1/users/me/subscriptions/videos'
46
47 return this.getRequestBody<ResultList<Video>>({
48 ...options,
49
50 path,
51 query: { sort },
52 implicitToken: true,
53 defaultExpectedStatus: HttpStatusCode.OK_200
54 })
55 }
56
57 get (options: OverrideCommandOptions & {
58 uri: string
59 }) {
60 const path = '/api/v1/users/me/subscriptions/' + options.uri
61
62 return this.getRequestBody<VideoChannel>({
63 ...options,
64
65 path,
66 implicitToken: true,
67 defaultExpectedStatus: HttpStatusCode.OK_200
68 })
69 }
70
71 remove (options: OverrideCommandOptions & {
72 uri: string
73 }) {
74 const path = '/api/v1/users/me/subscriptions/' + options.uri
75
76 return this.deleteRequest({
77 ...options,
78
79 path,
80 implicitToken: true,
81 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
82 })
83 }
84
85 exist (options: OverrideCommandOptions & {
86 uris: string[]
87 }) {
88 const path = '/api/v1/users/me/subscriptions/exist'
89
90 return this.getRequestBody<{ [id: string ]: boolean }>({
91 ...options,
92
93 path,
94 query: { 'uris[]': options.uris },
95 implicitToken: true,
96 defaultExpectedStatus: HttpStatusCode.OK_200
97 })
98 }
99}
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 @@
1import { omit } from 'lodash'
2import { pick } from '@shared/core-utils'
3import {
4 HttpStatusCode,
5 MyUser,
6 ResultList,
7 User,
8 UserAdminFlag,
9 UserCreateResult,
10 UserRole,
11 UserUpdate,
12 UserUpdateMe,
13 UserVideoQuota,
14 UserVideoRate
15} from '@shared/models'
16import { ScopedToken } from '@shared/models/users/user-scoped-token'
17import { unwrapBody } from '../requests'
18import { AbstractCommand, OverrideCommandOptions } from '../shared'
19
20export class UsersCommand extends AbstractCommand {
21
22 askResetPassword (options: OverrideCommandOptions & {
23 email: string
24 }) {
25 const { email } = options
26 const path = '/api/v1/users/ask-reset-password'
27
28 return this.postBodyRequest({
29 ...options,
30
31 path,
32 fields: { email },
33 implicitToken: false,
34 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
35 })
36 }
37
38 resetPassword (options: OverrideCommandOptions & {
39 userId: number
40 verificationString: string
41 password: string
42 }) {
43 const { userId, verificationString, password } = options
44 const path = '/api/v1/users/' + userId + '/reset-password'
45
46 return this.postBodyRequest({
47 ...options,
48
49 path,
50 fields: { password, verificationString },
51 implicitToken: false,
52 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
53 })
54 }
55
56 // ---------------------------------------------------------------------------
57
58 askSendVerifyEmail (options: OverrideCommandOptions & {
59 email: string
60 }) {
61 const { email } = options
62 const path = '/api/v1/users/ask-send-verify-email'
63
64 return this.postBodyRequest({
65 ...options,
66
67 path,
68 fields: { email },
69 implicitToken: false,
70 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
71 })
72 }
73
74 verifyEmail (options: OverrideCommandOptions & {
75 userId: number
76 verificationString: string
77 isPendingEmail?: boolean // default false
78 }) {
79 const { userId, verificationString, isPendingEmail = false } = options
80 const path = '/api/v1/users/' + userId + '/verify-email'
81
82 return this.postBodyRequest({
83 ...options,
84
85 path,
86 fields: {
87 verificationString,
88 isPendingEmail
89 },
90 implicitToken: false,
91 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
92 })
93 }
94
95 // ---------------------------------------------------------------------------
96
97 banUser (options: OverrideCommandOptions & {
98 userId: number
99 reason?: string
100 }) {
101 const { userId, reason } = options
102 const path = '/api/v1/users' + '/' + userId + '/block'
103
104 return this.postBodyRequest({
105 ...options,
106
107 path,
108 fields: { reason },
109 implicitToken: true,
110 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
111 })
112 }
113
114 unbanUser (options: OverrideCommandOptions & {
115 userId: number
116 }) {
117 const { userId } = options
118 const path = '/api/v1/users' + '/' + userId + '/unblock'
119
120 return this.postBodyRequest({
121 ...options,
122
123 path,
124 implicitToken: true,
125 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
126 })
127 }
128
129 // ---------------------------------------------------------------------------
130
131 getMyScopedTokens (options: OverrideCommandOptions = {}) {
132 const path = '/api/v1/users/scoped-tokens'
133
134 return this.getRequestBody<ScopedToken>({
135 ...options,
136
137 path,
138 implicitToken: true,
139 defaultExpectedStatus: HttpStatusCode.OK_200
140 })
141 }
142
143 renewMyScopedTokens (options: OverrideCommandOptions = {}) {
144 const path = '/api/v1/users/scoped-tokens'
145
146 return this.postBodyRequest({
147 ...options,
148
149 path,
150 implicitToken: true,
151 defaultExpectedStatus: HttpStatusCode.OK_200
152 })
153 }
154
155 // ---------------------------------------------------------------------------
156
157 create (options: OverrideCommandOptions & {
158 username: string
159 password?: string
160 videoQuota?: number
161 videoQuotaDaily?: number
162 role?: UserRole
163 adminFlags?: UserAdminFlag
164 }) {
165 const {
166 username,
167 adminFlags,
168 password = 'password',
169 videoQuota = 42000000,
170 videoQuotaDaily = -1,
171 role = UserRole.USER
172 } = options
173
174 const path = '/api/v1/users'
175
176 return unwrapBody<{ user: UserCreateResult }>(this.postBodyRequest({
177 ...options,
178
179 path,
180 fields: {
181 username,
182 password,
183 role,
184 adminFlags,
185 email: username + '@example.com',
186 videoQuota,
187 videoQuotaDaily
188 },
189 implicitToken: true,
190 defaultExpectedStatus: HttpStatusCode.OK_200
191 })).then(res => res.user)
192 }
193
194 async generate (username: string, role?: UserRole) {
195 const password = 'password'
196 const user = await this.create({ username, password, role })
197
198 const token = await this.server.login.getAccessToken({ username, password })
199
200 const me = await this.getMyInfo({ token })
201
202 return {
203 token,
204 userId: user.id,
205 userChannelId: me.videoChannels[0].id,
206 userChannelName: me.videoChannels[0].name
207 }
208 }
209
210 async generateUserAndToken (username: string, role?: UserRole) {
211 const password = 'password'
212 await this.create({ username, password, role })
213
214 return this.server.login.getAccessToken({ username, password })
215 }
216
217 register (options: OverrideCommandOptions & {
218 username: string
219 password?: string
220 displayName?: string
221 channel?: {
222 name: string
223 displayName: string
224 }
225 }) {
226 const { username, password = 'password', displayName, channel } = options
227 const path = '/api/v1/users/register'
228
229 return this.postBodyRequest({
230 ...options,
231
232 path,
233 fields: {
234 username,
235 password,
236 email: username + '@example.com',
237 displayName,
238 channel
239 },
240 implicitToken: false,
241 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
242 })
243 }
244
245 // ---------------------------------------------------------------------------
246
247 getMyInfo (options: OverrideCommandOptions = {}) {
248 const path = '/api/v1/users/me'
249
250 return this.getRequestBody<MyUser>({
251 ...options,
252
253 path,
254 implicitToken: true,
255 defaultExpectedStatus: HttpStatusCode.OK_200
256 })
257 }
258
259 getMyQuotaUsed (options: OverrideCommandOptions = {}) {
260 const path = '/api/v1/users/me/video-quota-used'
261
262 return this.getRequestBody<UserVideoQuota>({
263 ...options,
264
265 path,
266 implicitToken: true,
267 defaultExpectedStatus: HttpStatusCode.OK_200
268 })
269 }
270
271 getMyRating (options: OverrideCommandOptions & {
272 videoId: number | string
273 }) {
274 const { videoId } = options
275 const path = '/api/v1/users/me/videos/' + videoId + '/rating'
276
277 return this.getRequestBody<UserVideoRate>({
278 ...options,
279
280 path,
281 implicitToken: true,
282 defaultExpectedStatus: HttpStatusCode.OK_200
283 })
284 }
285
286 deleteMe (options: OverrideCommandOptions = {}) {
287 const path = '/api/v1/users/me'
288
289 return this.deleteRequest({
290 ...options,
291
292 path,
293 implicitToken: true,
294 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
295 })
296 }
297
298 updateMe (options: OverrideCommandOptions & UserUpdateMe) {
299 const path = '/api/v1/users/me'
300
301 const toSend: UserUpdateMe = omit(options, 'url', 'accessToken')
302
303 return this.putBodyRequest({
304 ...options,
305
306 path,
307 fields: toSend,
308 implicitToken: true,
309 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
310 })
311 }
312
313 updateMyAvatar (options: OverrideCommandOptions & {
314 fixture: string
315 }) {
316 const { fixture } = options
317 const path = '/api/v1/users/me/avatar/pick'
318
319 return this.updateImageRequest({
320 ...options,
321
322 path,
323 fixture,
324 fieldname: 'avatarfile',
325
326 implicitToken: true,
327 defaultExpectedStatus: HttpStatusCode.OK_200
328 })
329 }
330
331 // ---------------------------------------------------------------------------
332
333 get (options: OverrideCommandOptions & {
334 userId: number
335 withStats?: boolean // default false
336 }) {
337 const { userId, withStats } = options
338 const path = '/api/v1/users/' + userId
339
340 return this.getRequestBody<User>({
341 ...options,
342
343 path,
344 query: { withStats },
345 implicitToken: true,
346 defaultExpectedStatus: HttpStatusCode.OK_200
347 })
348 }
349
350 list (options: OverrideCommandOptions & {
351 start?: number
352 count?: number
353 sort?: string
354 search?: string
355 blocked?: boolean
356 } = {}) {
357 const path = '/api/v1/users'
358
359 return this.getRequestBody<ResultList<User>>({
360 ...options,
361
362 path,
363 query: pick(options, [ 'start', 'count', 'sort', 'search', 'blocked' ]),
364 implicitToken: true,
365 defaultExpectedStatus: HttpStatusCode.OK_200
366 })
367 }
368
369 remove (options: OverrideCommandOptions & {
370 userId: number
371 }) {
372 const { userId } = options
373 const path = '/api/v1/users/' + userId
374
375 return this.deleteRequest({
376 ...options,
377
378 path,
379 implicitToken: true,
380 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
381 })
382 }
383
384 update (options: OverrideCommandOptions & {
385 userId: number
386 email?: string
387 emailVerified?: boolean
388 videoQuota?: number
389 videoQuotaDaily?: number
390 password?: string
391 adminFlags?: UserAdminFlag
392 pluginAuth?: string
393 role?: UserRole
394 }) {
395 const path = '/api/v1/users/' + options.userId
396
397 const toSend: UserUpdate = {}
398 if (options.password !== undefined && options.password !== null) toSend.password = options.password
399 if (options.email !== undefined && options.email !== null) toSend.email = options.email
400 if (options.emailVerified !== undefined && options.emailVerified !== null) toSend.emailVerified = options.emailVerified
401 if (options.videoQuota !== undefined && options.videoQuota !== null) toSend.videoQuota = options.videoQuota
402 if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend.videoQuotaDaily = options.videoQuotaDaily
403 if (options.role !== undefined && options.role !== null) toSend.role = options.role
404 if (options.adminFlags !== undefined && options.adminFlags !== null) toSend.adminFlags = options.adminFlags
405 if (options.pluginAuth !== undefined) toSend.pluginAuth = options.pluginAuth
406
407 return this.putBodyRequest({
408 ...options,
409
410 path,
411 fields: toSend,
412 implicitToken: true,
413 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
414 })
415 }
416}
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 @@
1
2import { HttpStatusCode, ResultList } from '@shared/models'
3import { VideoBlacklist, VideoBlacklistType } from '../../models/videos'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class BlacklistCommand extends AbstractCommand {
7
8 add (options: OverrideCommandOptions & {
9 videoId: number | string
10 reason?: string
11 unfederate?: boolean
12 }) {
13 const { videoId, reason, unfederate } = options
14 const path = '/api/v1/videos/' + videoId + '/blacklist'
15
16 return this.postBodyRequest({
17 ...options,
18
19 path,
20 fields: { reason, unfederate },
21 implicitToken: true,
22 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
23 })
24 }
25
26 update (options: OverrideCommandOptions & {
27 videoId: number | string
28 reason?: string
29 }) {
30 const { videoId, reason } = options
31 const path = '/api/v1/videos/' + videoId + '/blacklist'
32
33 return this.putBodyRequest({
34 ...options,
35
36 path,
37 fields: { reason },
38 implicitToken: true,
39 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
40 })
41 }
42
43 remove (options: OverrideCommandOptions & {
44 videoId: number | string
45 }) {
46 const { videoId } = options
47 const path = '/api/v1/videos/' + videoId + '/blacklist'
48
49 return this.deleteRequest({
50 ...options,
51
52 path,
53 implicitToken: true,
54 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
55 })
56 }
57
58 list (options: OverrideCommandOptions & {
59 sort?: string
60 type?: VideoBlacklistType
61 } = {}) {
62 const { sort, type } = options
63 const path = '/api/v1/videos/blacklist/'
64
65 const query = { sort, type }
66
67 return this.getRequestBody<ResultList<VideoBlacklist>>({
68 ...options,
69
70 path,
71 query,
72 implicitToken: true,
73 defaultExpectedStatus: HttpStatusCode.OK_200
74 })
75 }
76}
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 @@
1import { HttpStatusCode, ResultList, VideoCaption } from '@shared/models'
2import { buildAbsoluteFixturePath } from '../miscs'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class CaptionsCommand extends AbstractCommand {
6
7 add (options: OverrideCommandOptions & {
8 videoId: string | number
9 language: string
10 fixture: string
11 mimeType?: string
12 }) {
13 const { videoId, language, fixture, mimeType } = options
14
15 const path = '/api/v1/videos/' + videoId + '/captions/' + language
16
17 const captionfile = buildAbsoluteFixturePath(fixture)
18 const captionfileAttach = mimeType
19 ? [ captionfile, { contentType: mimeType } ]
20 : captionfile
21
22 return this.putUploadRequest({
23 ...options,
24
25 path,
26 fields: {},
27 attaches: {
28 captionfile: captionfileAttach
29 },
30 implicitToken: true,
31 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
32 })
33 }
34
35 list (options: OverrideCommandOptions & {
36 videoId: string | number
37 }) {
38 const { videoId } = options
39 const path = '/api/v1/videos/' + videoId + '/captions'
40
41 return this.getRequestBody<ResultList<VideoCaption>>({
42 ...options,
43
44 path,
45 implicitToken: false,
46 defaultExpectedStatus: HttpStatusCode.OK_200
47 })
48 }
49
50 delete (options: OverrideCommandOptions & {
51 videoId: string | number
52 language: string
53 }) {
54 const { videoId, language } = options
55 const path = '/api/v1/videos/' + videoId + '/captions/' + language
56
57 return this.deleteRequest({
58 ...options,
59
60 path,
61 implicitToken: true,
62 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
63 })
64 }
65}
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 @@
1import { expect } from 'chai'
2import request from 'supertest'
3import { HttpStatusCode } from '@shared/models'
4
5async function testCaptionFile (url: string, captionPath: string, toTest: RegExp | string) {
6 const res = await request(url)
7 .get(captionPath)
8 .expect(HttpStatusCode.OK_200)
9
10 if (toTest instanceof RegExp) {
11 expect(res.text).to.match(toTest)
12 } else {
13 expect(res.text).to.contain(toTest)
14 }
15}
16
17// ---------------------------------------------------------------------------
18
19export {
20 testCaptionFile
21}
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 @@
1
2import { HttpStatusCode, ResultList, VideoChangeOwnership } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class ChangeOwnershipCommand extends AbstractCommand {
6
7 create (options: OverrideCommandOptions & {
8 videoId: number | string
9 username: string
10 }) {
11 const { videoId, username } = options
12 const path = '/api/v1/videos/' + videoId + '/give-ownership'
13
14 return this.postBodyRequest({
15 ...options,
16
17 path,
18 fields: { username },
19 implicitToken: true,
20 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
21 })
22 }
23
24 list (options: OverrideCommandOptions = {}) {
25 const path = '/api/v1/videos/ownership'
26
27 return this.getRequestBody<ResultList<VideoChangeOwnership>>({
28 ...options,
29
30 path,
31 query: { sort: '-createdAt' },
32 implicitToken: true,
33 defaultExpectedStatus: HttpStatusCode.OK_200
34 })
35 }
36
37 accept (options: OverrideCommandOptions & {
38 ownershipId: number
39 channelId: number
40 }) {
41 const { ownershipId, channelId } = options
42 const path = '/api/v1/videos/ownership/' + ownershipId + '/accept'
43
44 return this.postBodyRequest({
45 ...options,
46
47 path,
48 fields: { channelId },
49 implicitToken: true,
50 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
51 })
52 }
53
54 refuse (options: OverrideCommandOptions & {
55 ownershipId: number
56 }) {
57 const { ownershipId } = options
58 const path = '/api/v1/videos/ownership/' + ownershipId + '/refuse'
59
60 return this.postBodyRequest({
61 ...options,
62
63 path,
64 implicitToken: true,
65 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
66 })
67 }
68}
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 @@
1import { pick } from '@shared/core-utils'
2import { ActorFollow, HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models'
3import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model'
4import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model'
5import { unwrapBody } from '../requests'
6import { AbstractCommand, OverrideCommandOptions } from '../shared'
7
8export class ChannelsCommand extends AbstractCommand {
9
10 list (options: OverrideCommandOptions & {
11 start?: number
12 count?: number
13 sort?: string
14 withStats?: boolean
15 } = {}) {
16 const path = '/api/v1/video-channels'
17
18 return this.getRequestBody<ResultList<VideoChannel>>({
19 ...options,
20
21 path,
22 query: pick(options, [ 'start', 'count', 'sort', 'withStats' ]),
23 implicitToken: false,
24 defaultExpectedStatus: HttpStatusCode.OK_200
25 })
26 }
27
28 listByAccount (options: OverrideCommandOptions & {
29 accountName: string
30 start?: number
31 count?: number
32 sort?: string
33 withStats?: boolean
34 search?: string
35 }) {
36 const { accountName, sort = 'createdAt' } = options
37 const path = '/api/v1/accounts/' + accountName + '/video-channels'
38
39 return this.getRequestBody<ResultList<VideoChannel>>({
40 ...options,
41
42 path,
43 query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) },
44 implicitToken: false,
45 defaultExpectedStatus: HttpStatusCode.OK_200
46 })
47 }
48
49 async create (options: OverrideCommandOptions & {
50 attributes: Partial<VideoChannelCreate>
51 }) {
52 const path = '/api/v1/video-channels/'
53
54 // Default attributes
55 const defaultAttributes = {
56 displayName: 'my super video channel',
57 description: 'my super channel description',
58 support: 'my super channel support'
59 }
60 const attributes = { ...defaultAttributes, ...options.attributes }
61
62 const body = await unwrapBody<{ videoChannel: VideoChannelCreateResult }>(this.postBodyRequest({
63 ...options,
64
65 path,
66 fields: attributes,
67 implicitToken: true,
68 defaultExpectedStatus: HttpStatusCode.OK_200
69 }))
70
71 return body.videoChannel
72 }
73
74 update (options: OverrideCommandOptions & {
75 channelName: string
76 attributes: VideoChannelUpdate
77 }) {
78 const { channelName, attributes } = options
79 const path = '/api/v1/video-channels/' + channelName
80
81 return this.putBodyRequest({
82 ...options,
83
84 path,
85 fields: attributes,
86 implicitToken: true,
87 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
88 })
89 }
90
91 delete (options: OverrideCommandOptions & {
92 channelName: string
93 }) {
94 const path = '/api/v1/video-channels/' + options.channelName
95
96 return this.deleteRequest({
97 ...options,
98
99 path,
100 implicitToken: true,
101 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
102 })
103 }
104
105 get (options: OverrideCommandOptions & {
106 channelName: string
107 }) {
108 const path = '/api/v1/video-channels/' + options.channelName
109
110 return this.getRequestBody<VideoChannel>({
111 ...options,
112
113 path,
114 implicitToken: false,
115 defaultExpectedStatus: HttpStatusCode.OK_200
116 })
117 }
118
119 updateImage (options: OverrideCommandOptions & {
120 fixture: string
121 channelName: string | number
122 type: 'avatar' | 'banner'
123 }) {
124 const { channelName, fixture, type } = options
125
126 const path = `/api/v1/video-channels/${channelName}/${type}/pick`
127
128 return this.updateImageRequest({
129 ...options,
130
131 path,
132 fixture,
133 fieldname: type + 'file',
134
135 implicitToken: true,
136 defaultExpectedStatus: HttpStatusCode.OK_200
137 })
138 }
139
140 deleteImage (options: OverrideCommandOptions & {
141 channelName: string | number
142 type: 'avatar' | 'banner'
143 }) {
144 const { channelName, type } = options
145
146 const path = `/api/v1/video-channels/${channelName}/${type}`
147
148 return this.deleteRequest({
149 ...options,
150
151 path,
152 implicitToken: true,
153 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
154 })
155 }
156
157 listFollowers (options: OverrideCommandOptions & {
158 channelName: string
159 start?: number
160 count?: number
161 sort?: string
162 search?: string
163 }) {
164 const { channelName, start, count, sort, search } = options
165 const path = '/api/v1/video-channels/' + channelName + '/followers'
166
167 const query = { start, count, sort, search }
168
169 return this.getRequestBody<ResultList<ActorFollow>>({
170 ...options,
171
172 path,
173 query,
174 implicitToken: true,
175 defaultExpectedStatus: HttpStatusCode.OK_200
176 })
177 }
178}
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 @@
1import { PeerTubeServer } from '../server/server'
2
3function setDefaultVideoChannel (servers: PeerTubeServer[]) {
4 const tasks: Promise<any>[] = []
5
6 for (const server of servers) {
7 const p = server.users.getMyInfo()
8 .then(user => { server.store.channel = user.videoChannels[0] })
9
10 tasks.push(p)
11 }
12
13 return Promise.all(tasks)
14}
15
16export {
17 setDefaultVideoChannel
18}
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 @@
1import { pick } from 'lodash'
2import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@shared/models'
3import { unwrapBody } from '../requests'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class CommentsCommand extends AbstractCommand {
7
8 private lastVideoId: number | string
9 private lastThreadId: number
10 private lastReplyId: number
11
12 listForAdmin (options: OverrideCommandOptions & {
13 start?: number
14 count?: number
15 sort?: string
16 isLocal?: boolean
17 search?: string
18 searchAccount?: string
19 searchVideo?: string
20 } = {}) {
21 const { sort = '-createdAt' } = options
22 const path = '/api/v1/videos/comments'
23
24 const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'search', 'searchAccount', 'searchVideo' ]) }
25
26 return this.getRequestBody<ResultList<VideoComment>>({
27 ...options,
28
29 path,
30 query,
31 implicitToken: true,
32 defaultExpectedStatus: HttpStatusCode.OK_200
33 })
34 }
35
36 listThreads (options: OverrideCommandOptions & {
37 videoId: number | string
38 start?: number
39 count?: number
40 sort?: string
41 }) {
42 const { start, count, sort, videoId } = options
43 const path = '/api/v1/videos/' + videoId + '/comment-threads'
44
45 return this.getRequestBody<VideoCommentThreads>({
46 ...options,
47
48 path,
49 query: { start, count, sort },
50 implicitToken: false,
51 defaultExpectedStatus: HttpStatusCode.OK_200
52 })
53 }
54
55 getThread (options: OverrideCommandOptions & {
56 videoId: number | string
57 threadId: number
58 }) {
59 const { videoId, threadId } = options
60 const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId
61
62 return this.getRequestBody<VideoCommentThreadTree>({
63 ...options,
64
65 path,
66 implicitToken: false,
67 defaultExpectedStatus: HttpStatusCode.OK_200
68 })
69 }
70
71 async createThread (options: OverrideCommandOptions & {
72 videoId: number | string
73 text: string
74 }) {
75 const { videoId, text } = options
76 const path = '/api/v1/videos/' + videoId + '/comment-threads'
77
78 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
79 ...options,
80
81 path,
82 fields: { text },
83 implicitToken: true,
84 defaultExpectedStatus: HttpStatusCode.OK_200
85 }))
86
87 this.lastThreadId = body.comment?.id
88 this.lastVideoId = videoId
89
90 return body.comment
91 }
92
93 async addReply (options: OverrideCommandOptions & {
94 videoId: number | string
95 toCommentId: number
96 text: string
97 }) {
98 const { videoId, toCommentId, text } = options
99 const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId
100
101 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
102 ...options,
103
104 path,
105 fields: { text },
106 implicitToken: true,
107 defaultExpectedStatus: HttpStatusCode.OK_200
108 }))
109
110 this.lastReplyId = body.comment?.id
111
112 return body.comment
113 }
114
115 async addReplyToLastReply (options: OverrideCommandOptions & {
116 text: string
117 }) {
118 return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId })
119 }
120
121 async addReplyToLastThread (options: OverrideCommandOptions & {
122 text: string
123 }) {
124 return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId })
125 }
126
127 async findCommentId (options: OverrideCommandOptions & {
128 videoId: number | string
129 text: string
130 }) {
131 const { videoId, text } = options
132 const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' })
133
134 return data.find(c => c.text === text).id
135 }
136
137 delete (options: OverrideCommandOptions & {
138 videoId: number | string
139 commentId: number
140 }) {
141 const { videoId, commentId } = options
142 const path = '/api/v1/videos/' + videoId + '/comments/' + commentId
143
144 return this.deleteRequest({
145 ...options,
146
147 path,
148 implicitToken: true,
149 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
150 })
151 }
152}
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 @@
1import { HttpStatusCode, ResultList, Video } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class HistoryCommand extends AbstractCommand {
5
6 wathVideo (options: OverrideCommandOptions & {
7 videoId: number | string
8 currentTime: number
9 }) {
10 const { videoId, currentTime } = options
11
12 const path = '/api/v1/videos/' + videoId + '/watching'
13 const fields = { currentTime }
14
15 return this.putBodyRequest({
16 ...options,
17
18 path,
19 fields,
20 implicitToken: true,
21 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
22 })
23 }
24
25 list (options: OverrideCommandOptions & {
26 search?: string
27 } = {}) {
28 const { search } = options
29 const path = '/api/v1/users/me/history/videos'
30
31 return this.getRequestBody<ResultList<Video>>({
32 ...options,
33
34 path,
35 query: {
36 search
37 },
38 implicitToken: true,
39 defaultExpectedStatus: HttpStatusCode.OK_200
40 })
41 }
42
43 remove (options: OverrideCommandOptions & {
44 beforeDate?: string
45 } = {}) {
46 const { beforeDate } = options
47 const path = '/api/v1/users/me/history/videos/remove'
48
49 return this.postBodyRequest({
50 ...options,
51
52 path,
53 fields: { beforeDate },
54 implicitToken: true,
55 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
56 })
57 }
58}
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 @@
1
2import { HttpStatusCode, ResultList } from '@shared/models'
3import { VideoImport, VideoImportCreate } from '../../models/videos'
4import { unwrapBody } from '../requests'
5import { AbstractCommand, OverrideCommandOptions } from '../shared'
6
7export class ImportsCommand extends AbstractCommand {
8
9 importVideo (options: OverrideCommandOptions & {
10 attributes: VideoImportCreate & { torrentfile?: string }
11 }) {
12 const { attributes } = options
13 const path = '/api/v1/videos/imports'
14
15 let attaches: any = {}
16 if (attributes.torrentfile) attaches = { torrentfile: attributes.torrentfile }
17
18 return unwrapBody<VideoImport>(this.postUploadRequest({
19 ...options,
20
21 path,
22 attaches,
23 fields: options.attributes,
24 implicitToken: true,
25 defaultExpectedStatus: HttpStatusCode.OK_200
26 }))
27 }
28
29 getMyVideoImports (options: OverrideCommandOptions & {
30 sort?: string
31 } = {}) {
32 const { sort } = options
33 const path = '/api/v1/users/me/videos/imports'
34
35 const query = {}
36 if (sort) query['sort'] = sort
37
38 return this.getRequestBody<ResultList<VideoImport>>({
39 ...options,
40
41 path,
42 query: { sort },
43 implicitToken: true,
44 defaultExpectedStatus: HttpStatusCode.OK_200
45 })
46 }
47}
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 @@
1export * from './blacklist-command'
2export * from './captions-command'
3export * from './captions'
4export * from './change-ownership-command'
5export * from './channels'
6export * from './channels-command'
7export * from './comments-command'
8export * from './history-command'
9export * from './imports-command'
10export * from './live-command'
11export * from './live'
12export * from './playlists-command'
13export * from './playlists'
14export * from './services-command'
15export * from './streaming-playlists-command'
16export * from './streaming-playlists'
17export * from './comments-command'
18export * from './videos-command'
19export * 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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { readdir } from 'fs-extra'
4import { omit } from 'lodash'
5import { join } from 'path'
6import { HttpStatusCode, LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoCreateResult, VideoDetails, VideoState } from '@shared/models'
7import { wait } from '../miscs'
8import { unwrapBody } from '../requests'
9import { AbstractCommand, OverrideCommandOptions } from '../shared'
10import { sendRTMPStream, testFfmpegStreamError } from './live'
11
12export class LiveCommand extends AbstractCommand {
13
14 get (options: OverrideCommandOptions & {
15 videoId: number | string
16 }) {
17 const path = '/api/v1/videos/live'
18
19 return this.getRequestBody<LiveVideo>({
20 ...options,
21
22 path: path + '/' + options.videoId,
23 implicitToken: true,
24 defaultExpectedStatus: HttpStatusCode.OK_200
25 })
26 }
27
28 update (options: OverrideCommandOptions & {
29 videoId: number | string
30 fields: LiveVideoUpdate
31 }) {
32 const { videoId, fields } = options
33 const path = '/api/v1/videos/live'
34
35 return this.putBodyRequest({
36 ...options,
37
38 path: path + '/' + videoId,
39 fields,
40 implicitToken: true,
41 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
42 })
43 }
44
45 async create (options: OverrideCommandOptions & {
46 fields: LiveVideoCreate
47 }) {
48 const { fields } = options
49 const path = '/api/v1/videos/live'
50
51 const attaches: any = {}
52 if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile
53 if (fields.previewfile) attaches.previewfile = fields.previewfile
54
55 const body = await unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
56 ...options,
57
58 path,
59 attaches,
60 fields: omit(fields, 'thumbnailfile', 'previewfile'),
61 implicitToken: true,
62 defaultExpectedStatus: HttpStatusCode.OK_200
63 }))
64
65 return body.video
66 }
67
68 async sendRTMPStreamInVideo (options: OverrideCommandOptions & {
69 videoId: number | string
70 fixtureName?: string
71 copyCodecs?: boolean
72 }) {
73 const { videoId, fixtureName, copyCodecs } = options
74 const videoLive = await this.get({ videoId })
75
76 return sendRTMPStream({ rtmpBaseUrl: videoLive.rtmpUrl, streamKey: videoLive.streamKey, fixtureName, copyCodecs })
77 }
78
79 async runAndTestStreamError (options: OverrideCommandOptions & {
80 videoId: number | string
81 shouldHaveError: boolean
82 }) {
83 const command = await this.sendRTMPStreamInVideo(options)
84
85 return testFfmpegStreamError(command, options.shouldHaveError)
86 }
87
88 waitUntilPublished (options: OverrideCommandOptions & {
89 videoId: number | string
90 }) {
91 const { videoId } = options
92 return this.waitUntilState({ videoId, state: VideoState.PUBLISHED })
93 }
94
95 waitUntilWaiting (options: OverrideCommandOptions & {
96 videoId: number | string
97 }) {
98 const { videoId } = options
99 return this.waitUntilState({ videoId, state: VideoState.WAITING_FOR_LIVE })
100 }
101
102 waitUntilEnded (options: OverrideCommandOptions & {
103 videoId: number | string
104 }) {
105 const { videoId } = options
106 return this.waitUntilState({ videoId, state: VideoState.LIVE_ENDED })
107 }
108
109 waitUntilSegmentGeneration (options: OverrideCommandOptions & {
110 videoUUID: string
111 resolution: number
112 segment: number
113 }) {
114 const { resolution, segment, videoUUID } = options
115 const segmentName = `${resolution}-00000${segment}.ts`
116
117 return this.server.servers.waitUntilLog(`${videoUUID}/${segmentName}`, 2, false)
118 }
119
120 async waitUntilSaved (options: OverrideCommandOptions & {
121 videoId: number | string
122 }) {
123 let video: VideoDetails
124
125 do {
126 video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId })
127
128 await wait(500)
129 } while (video.isLive === true || video.state.id !== VideoState.PUBLISHED)
130 }
131
132 async countPlaylists (options: OverrideCommandOptions & {
133 videoUUID: string
134 }) {
135 const basePath = this.server.servers.buildDirectory('streaming-playlists')
136 const hlsPath = join(basePath, 'hls', options.videoUUID)
137
138 const files = await readdir(hlsPath)
139
140 return files.filter(f => f.endsWith('.m3u8')).length
141 }
142
143 private async waitUntilState (options: OverrideCommandOptions & {
144 videoId: number | string
145 state: VideoState
146 }) {
147 let video: VideoDetails
148
149 do {
150 video = await this.server.videos.getWithToken({ token: options.token, id: options.videoId })
151
152 await wait(500)
153 } while (video.state.id !== options.state)
154 }
155}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
5import { pathExists, readdir } from 'fs-extra'
6import { join } from 'path'
7import { buildAbsoluteFixturePath, wait } from '../miscs'
8import { PeerTubeServer } from '../server/server'
9
10function sendRTMPStream (options: {
11 rtmpBaseUrl: string
12 streamKey: string
13 fixtureName?: string // default video_short.mp4
14 copyCodecs?: boolean // default false
15}) {
16 const { rtmpBaseUrl, streamKey, fixtureName = 'video_short.mp4', copyCodecs = false } = options
17
18 const fixture = buildAbsoluteFixturePath(fixtureName)
19
20 const command = ffmpeg(fixture)
21 command.inputOption('-stream_loop -1')
22 command.inputOption('-re')
23
24 if (copyCodecs) {
25 command.outputOption('-c copy')
26 } else {
27 command.outputOption('-c:v libx264')
28 command.outputOption('-g 50')
29 command.outputOption('-keyint_min 2')
30 command.outputOption('-r 60')
31 }
32
33 command.outputOption('-f flv')
34
35 const rtmpUrl = rtmpBaseUrl + '/' + streamKey
36 command.output(rtmpUrl)
37
38 command.on('error', err => {
39 if (err?.message?.includes('Exiting normally')) return
40
41 if (process.env.DEBUG) console.error(err)
42 })
43
44 if (process.env.DEBUG) {
45 command.on('stderr', data => console.log(data))
46 }
47
48 command.run()
49
50 return command
51}
52
53function waitFfmpegUntilError (command: FfmpegCommand, successAfterMS = 10000) {
54 return new Promise<void>((res, rej) => {
55 command.on('error', err => {
56 return rej(err)
57 })
58
59 setTimeout(() => {
60 res()
61 }, successAfterMS)
62 })
63}
64
65async function testFfmpegStreamError (command: FfmpegCommand, shouldHaveError: boolean) {
66 let error: Error
67
68 try {
69 await waitFfmpegUntilError(command, 35000)
70 } catch (err) {
71 error = err
72 }
73
74 await stopFfmpeg(command)
75
76 if (shouldHaveError && !error) throw new Error('Ffmpeg did not have an error')
77 if (!shouldHaveError && error) throw error
78}
79
80async function stopFfmpeg (command: FfmpegCommand) {
81 command.kill('SIGINT')
82
83 await wait(500)
84}
85
86async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], videoId: string) {
87 for (const server of servers) {
88 await server.live.waitUntilPublished({ videoId })
89 }
90}
91
92async function waitUntilLiveSavedOnAllServers (servers: PeerTubeServer[], videoId: string) {
93 for (const server of servers) {
94 await server.live.waitUntilSaved({ videoId })
95 }
96}
97
98async function checkLiveCleanupAfterSave (server: PeerTubeServer, videoUUID: string, resolutions: number[] = []) {
99 const basePath = server.servers.buildDirectory('streaming-playlists')
100 const hlsPath = join(basePath, 'hls', videoUUID)
101
102 if (resolutions.length === 0) {
103 const result = await pathExists(hlsPath)
104 expect(result).to.be.false
105
106 return
107 }
108
109 const files = await readdir(hlsPath)
110
111 // fragmented file and playlist per resolution + master playlist + segments sha256 json file
112 expect(files).to.have.lengthOf(resolutions.length * 2 + 2)
113
114 for (const resolution of resolutions) {
115 const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`))
116 expect(fragmentedFile).to.exist
117
118 const playlistFile = files.find(f => f.endsWith(`${resolution}.m3u8`))
119 expect(playlistFile).to.exist
120 }
121
122 const masterPlaylistFile = files.find(f => f.endsWith('-master.m3u8'))
123 expect(masterPlaylistFile).to.exist
124
125 const shaFile = files.find(f => f.endsWith('-segments-sha256.json'))
126 expect(shaFile).to.exist
127}
128
129export {
130 sendRTMPStream,
131 waitFfmpegUntilError,
132 testFfmpegStreamError,
133 stopFfmpeg,
134 waitUntilLivePublishedOnAllServers,
135 waitUntilLiveSavedOnAllServers,
136 checkLiveCleanupAfterSave
137}
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 @@
1import { omit } from 'lodash'
2import { pick } from '@shared/core-utils'
3import {
4 BooleanBothQuery,
5 HttpStatusCode,
6 ResultList,
7 VideoExistInPlaylist,
8 VideoPlaylist,
9 VideoPlaylistCreate,
10 VideoPlaylistCreateResult,
11 VideoPlaylistElement,
12 VideoPlaylistElementCreate,
13 VideoPlaylistElementCreateResult,
14 VideoPlaylistElementUpdate,
15 VideoPlaylistReorder,
16 VideoPlaylistType,
17 VideoPlaylistUpdate
18} from '@shared/models'
19import { unwrapBody } from '../requests'
20import { AbstractCommand, OverrideCommandOptions } from '../shared'
21
22export class PlaylistsCommand extends AbstractCommand {
23
24 list (options: OverrideCommandOptions & {
25 start?: number
26 count?: number
27 sort?: string
28 }) {
29 const path = '/api/v1/video-playlists'
30 const query = pick(options, [ 'start', 'count', 'sort' ])
31
32 return this.getRequestBody<ResultList<VideoPlaylist>>({
33 ...options,
34
35 path,
36 query,
37 implicitToken: false,
38 defaultExpectedStatus: HttpStatusCode.OK_200
39 })
40 }
41
42 listByChannel (options: OverrideCommandOptions & {
43 handle: string
44 start?: number
45 count?: number
46 sort?: string
47 }) {
48 const path = '/api/v1/video-channels/' + options.handle + '/video-playlists'
49 const query = pick(options, [ 'start', 'count', 'sort' ])
50
51 return this.getRequestBody<ResultList<VideoPlaylist>>({
52 ...options,
53
54 path,
55 query,
56 implicitToken: false,
57 defaultExpectedStatus: HttpStatusCode.OK_200
58 })
59 }
60
61 listByAccount (options: OverrideCommandOptions & {
62 handle: string
63 start?: number
64 count?: number
65 sort?: string
66 search?: string
67 playlistType?: VideoPlaylistType
68 }) {
69 const path = '/api/v1/accounts/' + options.handle + '/video-playlists'
70 const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ])
71
72 return this.getRequestBody<ResultList<VideoPlaylist>>({
73 ...options,
74
75 path,
76 query,
77 implicitToken: false,
78 defaultExpectedStatus: HttpStatusCode.OK_200
79 })
80 }
81
82 get (options: OverrideCommandOptions & {
83 playlistId: number | string
84 }) {
85 const { playlistId } = options
86 const path = '/api/v1/video-playlists/' + playlistId
87
88 return this.getRequestBody<VideoPlaylist>({
89 ...options,
90
91 path,
92 implicitToken: false,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 listVideos (options: OverrideCommandOptions & {
98 playlistId: number | string
99 start?: number
100 count?: number
101 query?: { nsfw?: BooleanBothQuery }
102 }) {
103 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
104 const query = options.query ?? {}
105
106 return this.getRequestBody<ResultList<VideoPlaylistElement>>({
107 ...options,
108
109 path,
110 query: {
111 ...query,
112 start: options.start,
113 count: options.count
114 },
115 implicitToken: true,
116 defaultExpectedStatus: HttpStatusCode.OK_200
117 })
118 }
119
120 delete (options: OverrideCommandOptions & {
121 playlistId: number | string
122 }) {
123 const path = '/api/v1/video-playlists/' + options.playlistId
124
125 return this.deleteRequest({
126 ...options,
127
128 path,
129 implicitToken: true,
130 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
131 })
132 }
133
134 async create (options: OverrideCommandOptions & {
135 attributes: VideoPlaylistCreate
136 }) {
137 const path = '/api/v1/video-playlists'
138
139 const fields = omit(options.attributes, 'thumbnailfile')
140
141 const attaches = options.attributes.thumbnailfile
142 ? { thumbnailfile: options.attributes.thumbnailfile }
143 : {}
144
145 const body = await unwrapBody<{ videoPlaylist: VideoPlaylistCreateResult }>(this.postUploadRequest({
146 ...options,
147
148 path,
149 fields,
150 attaches,
151 implicitToken: true,
152 defaultExpectedStatus: HttpStatusCode.OK_200
153 }))
154
155 return body.videoPlaylist
156 }
157
158 update (options: OverrideCommandOptions & {
159 attributes: VideoPlaylistUpdate
160 playlistId: number | string
161 }) {
162 const path = '/api/v1/video-playlists/' + options.playlistId
163
164 const fields = omit(options.attributes, 'thumbnailfile')
165
166 const attaches = options.attributes.thumbnailfile
167 ? { thumbnailfile: options.attributes.thumbnailfile }
168 : {}
169
170 return this.putUploadRequest({
171 ...options,
172
173 path,
174 fields,
175 attaches,
176 implicitToken: true,
177 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
178 })
179 }
180
181 async addElement (options: OverrideCommandOptions & {
182 playlistId: number | string
183 attributes: VideoPlaylistElementCreate | { videoId: string }
184 }) {
185 const attributes = {
186 ...options.attributes,
187
188 videoId: await this.server.videos.getId({ ...options, uuid: options.attributes.videoId })
189 }
190
191 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos'
192
193 const body = await unwrapBody<{ videoPlaylistElement: VideoPlaylistElementCreateResult }>(this.postBodyRequest({
194 ...options,
195
196 path,
197 fields: attributes,
198 implicitToken: true,
199 defaultExpectedStatus: HttpStatusCode.OK_200
200 }))
201
202 return body.videoPlaylistElement
203 }
204
205 updateElement (options: OverrideCommandOptions & {
206 playlistId: number | string
207 elementId: number | string
208 attributes: VideoPlaylistElementUpdate
209 }) {
210 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
211
212 return this.putBodyRequest({
213 ...options,
214
215 path,
216 fields: options.attributes,
217 implicitToken: true,
218 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
219 })
220 }
221
222 removeElement (options: OverrideCommandOptions & {
223 playlistId: number | string
224 elementId: number
225 }) {
226 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.elementId
227
228 return this.deleteRequest({
229 ...options,
230
231 path,
232 implicitToken: true,
233 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
234 })
235 }
236
237 reorderElements (options: OverrideCommandOptions & {
238 playlistId: number | string
239 attributes: VideoPlaylistReorder
240 }) {
241 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/reorder'
242
243 return this.postBodyRequest({
244 ...options,
245
246 path,
247 fields: options.attributes,
248 implicitToken: true,
249 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
250 })
251 }
252
253 getPrivacies (options: OverrideCommandOptions = {}) {
254 const path = '/api/v1/video-playlists/privacies'
255
256 return this.getRequestBody<{ [ id: number ]: string }>({
257 ...options,
258
259 path,
260 implicitToken: false,
261 defaultExpectedStatus: HttpStatusCode.OK_200
262 })
263 }
264
265 videosExist (options: OverrideCommandOptions & {
266 videoIds: number[]
267 }) {
268 const { videoIds } = options
269 const path = '/api/v1/users/me/video-playlists/videos-exist'
270
271 return this.getRequestBody<VideoExistInPlaylist>({
272 ...options,
273
274 path,
275 query: { videoIds },
276 implicitToken: true,
277 defaultExpectedStatus: HttpStatusCode.OK_200
278 })
279 }
280}
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 @@
1import { expect } from 'chai'
2import { readdir } from 'fs-extra'
3import { join } from 'path'
4import { root } from '../miscs'
5
6async function checkPlaylistFilesWereRemoved (
7 playlistUUID: string,
8 internalServerNumber: number,
9 directories = [ 'thumbnails' ]
10) {
11 const testDirectory = 'test' + internalServerNumber
12
13 for (const directory of directories) {
14 const directoryPath = join(root(), testDirectory, directory)
15
16 const files = await readdir(directoryPath)
17 for (const file of files) {
18 expect(file).to.not.contain(playlistUUID)
19 }
20 }
21}
22
23export {
24 checkPlaylistFilesWereRemoved
25}
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 @@
1import { HttpStatusCode } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class ServicesCommand extends AbstractCommand {
5
6 getOEmbed (options: OverrideCommandOptions & {
7 oembedUrl: string
8 format?: string
9 maxHeight?: number
10 maxWidth?: number
11 }) {
12 const path = '/services/oembed'
13 const query = {
14 url: options.oembedUrl,
15 format: options.format,
16 maxheight: options.maxHeight,
17 maxwidth: options.maxWidth
18 }
19
20 return this.getRequest({
21 ...options,
22
23 path,
24 query,
25 implicitToken: false,
26 defaultExpectedStatus: HttpStatusCode.OK_200
27 })
28 }
29}
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 @@
1import { HttpStatusCode } from '@shared/models'
2import { unwrapBody, unwrapTextOrDecode, unwrapBodyOrDecodeToJSON } from '../requests'
3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4
5export class StreamingPlaylistsCommand extends AbstractCommand {
6
7 get (options: OverrideCommandOptions & {
8 url: string
9 }) {
10 return unwrapTextOrDecode(this.getRawRequest({
11 ...options,
12
13 url: options.url,
14 implicitToken: false,
15 defaultExpectedStatus: HttpStatusCode.OK_200
16 }))
17 }
18
19 getSegment (options: OverrideCommandOptions & {
20 url: string
21 range?: string
22 }) {
23 return unwrapBody<Buffer>(this.getRawRequest({
24 ...options,
25
26 url: options.url,
27 range: options.range,
28 implicitToken: false,
29 defaultExpectedStatus: HttpStatusCode.OK_200
30 }))
31 }
32
33 getSegmentSha256 (options: OverrideCommandOptions & {
34 url: string
35 }) {
36 return unwrapBodyOrDecodeToJSON<{ [ id: string ]: string }>(this.getRawRequest({
37 ...options,
38
39 url: options.url,
40 implicitToken: false,
41 defaultExpectedStatus: HttpStatusCode.OK_200
42 }))
43 }
44}
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 @@
1import { expect } from 'chai'
2import { basename } from 'path'
3import { sha256 } from '@shared/core-utils/crypto'
4import { removeFragmentedMP4Ext } from '@shared/core-utils'
5import { HttpStatusCode, VideoStreamingPlaylist } from '@shared/models'
6import { PeerTubeServer } from '../server'
7
8async function checkSegmentHash (options: {
9 server: PeerTubeServer
10 baseUrlPlaylist: string
11 baseUrlSegment: string
12 resolution: number
13 hlsPlaylist: VideoStreamingPlaylist
14}) {
15 const { server, baseUrlPlaylist, baseUrlSegment, resolution, hlsPlaylist } = options
16 const command = server.streamingPlaylists
17
18 const file = hlsPlaylist.files.find(f => f.resolution.id === resolution)
19 const videoName = basename(file.fileUrl)
20
21 const playlist = await command.get({ url: `${baseUrlPlaylist}/${removeFragmentedMP4Ext(videoName)}.m3u8` })
22
23 const matches = /#EXT-X-BYTERANGE:(\d+)@(\d+)/.exec(playlist)
24
25 const length = parseInt(matches[1], 10)
26 const offset = parseInt(matches[2], 10)
27 const range = `${offset}-${offset + length - 1}`
28
29 const segmentBody = await command.getSegment({
30 url: `${baseUrlSegment}/${videoName}`,
31 expectedStatus: HttpStatusCode.PARTIAL_CONTENT_206,
32 range: `bytes=${range}`
33 })
34
35 const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url })
36 expect(sha256(segmentBody)).to.equal(shaBody[videoName][range])
37}
38
39async function checkLiveSegmentHash (options: {
40 server: PeerTubeServer
41 baseUrlSegment: string
42 videoUUID: string
43 segmentName: string
44 hlsPlaylist: VideoStreamingPlaylist
45}) {
46 const { server, baseUrlSegment, videoUUID, segmentName, hlsPlaylist } = options
47 const command = server.streamingPlaylists
48
49 const segmentBody = await command.getSegment({ url: `${baseUrlSegment}/${videoUUID}/${segmentName}` })
50 const shaBody = await command.getSegmentSha256({ url: hlsPlaylist.segmentsSha256Url })
51
52 expect(sha256(segmentBody)).to.equal(shaBody[segmentName])
53}
54
55async function checkResolutionsInMasterPlaylist (options: {
56 server: PeerTubeServer
57 playlistUrl: string
58 resolutions: number[]
59}) {
60 const { server, playlistUrl, resolutions } = options
61
62 const masterPlaylist = await server.streamingPlaylists.get({ url: playlistUrl })
63
64 for (const resolution of resolutions) {
65 const reg = new RegExp(
66 '#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + ',(FRAME-RATE=\\d+,)?CODECS="avc1.64001f,mp4a.40.2"'
67 )
68
69 expect(masterPlaylist).to.match(reg)
70 }
71}
72
73export {
74 checkSegmentHash,
75 checkLiveSegmentHash,
76 checkResolutionsInMasterPlaylist
77}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { createReadStream, stat } from 'fs-extra'
5import got, { Response as GotResponse } from 'got'
6import { omit } from 'lodash'
7import validator from 'validator'
8import { buildUUID } from '@shared/core-utils/uuid'
9import { pick } from '@shared/core-utils'
10import {
11 HttpStatusCode,
12 ResultList,
13 UserVideoRateType,
14 Video,
15 VideoCreate,
16 VideoCreateResult,
17 VideoDetails,
18 VideoFileMetadata,
19 VideoPrivacy,
20 VideosCommonQuery,
21 VideoTranscodingCreate
22} from '@shared/models'
23import { buildAbsoluteFixturePath, wait } from '../miscs'
24import { unwrapBody } from '../requests'
25import { waitJobs } from '../server'
26import { AbstractCommand, OverrideCommandOptions } from '../shared'
27
28export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
29 fixture?: string
30 thumbnailfile?: string
31 previewfile?: string
32}
33
34export class VideosCommand extends AbstractCommand {
35 getCategories (options: OverrideCommandOptions = {}) {
36 const path = '/api/v1/videos/categories'
37
38 return this.getRequestBody<{ [id: number]: string }>({
39 ...options,
40 path,
41
42 implicitToken: false,
43 defaultExpectedStatus: HttpStatusCode.OK_200
44 })
45 }
46
47 getLicences (options: OverrideCommandOptions = {}) {
48 const path = '/api/v1/videos/licences'
49
50 return this.getRequestBody<{ [id: number]: string }>({
51 ...options,
52 path,
53
54 implicitToken: false,
55 defaultExpectedStatus: HttpStatusCode.OK_200
56 })
57 }
58
59 getLanguages (options: OverrideCommandOptions = {}) {
60 const path = '/api/v1/videos/languages'
61
62 return this.getRequestBody<{ [id: string]: string }>({
63 ...options,
64 path,
65
66 implicitToken: false,
67 defaultExpectedStatus: HttpStatusCode.OK_200
68 })
69 }
70
71 getPrivacies (options: OverrideCommandOptions = {}) {
72 const path = '/api/v1/videos/privacies'
73
74 return this.getRequestBody<{ [id in VideoPrivacy]: string }>({
75 ...options,
76 path,
77
78 implicitToken: false,
79 defaultExpectedStatus: HttpStatusCode.OK_200
80 })
81 }
82
83 // ---------------------------------------------------------------------------
84
85 getDescription (options: OverrideCommandOptions & {
86 descriptionPath: string
87 }) {
88 return this.getRequestBody<{ description: string }>({
89 ...options,
90 path: options.descriptionPath,
91
92 implicitToken: false,
93 defaultExpectedStatus: HttpStatusCode.OK_200
94 })
95 }
96
97 getFileMetadata (options: OverrideCommandOptions & {
98 url: string
99 }) {
100 return unwrapBody<VideoFileMetadata>(this.getRawRequest({
101 ...options,
102
103 url: options.url,
104 implicitToken: false,
105 defaultExpectedStatus: HttpStatusCode.OK_200
106 }))
107 }
108
109 // ---------------------------------------------------------------------------
110
111 view (options: OverrideCommandOptions & {
112 id: number | string
113 xForwardedFor?: string
114 }) {
115 const { id, xForwardedFor } = options
116 const path = '/api/v1/videos/' + id + '/views'
117
118 return this.postBodyRequest({
119 ...options,
120
121 path,
122 xForwardedFor,
123 implicitToken: false,
124 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
125 })
126 }
127
128 rate (options: OverrideCommandOptions & {
129 id: number | string
130 rating: UserVideoRateType
131 }) {
132 const { id, rating } = options
133 const path = '/api/v1/videos/' + id + '/rate'
134
135 return this.putBodyRequest({
136 ...options,
137
138 path,
139 fields: { rating },
140 implicitToken: true,
141 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
142 })
143 }
144
145 // ---------------------------------------------------------------------------
146
147 get (options: OverrideCommandOptions & {
148 id: number | string
149 }) {
150 const path = '/api/v1/videos/' + options.id
151
152 return this.getRequestBody<VideoDetails>({
153 ...options,
154
155 path,
156 implicitToken: false,
157 defaultExpectedStatus: HttpStatusCode.OK_200
158 })
159 }
160
161 getWithToken (options: OverrideCommandOptions & {
162 id: number | string
163 }) {
164 return this.get({
165 ...options,
166
167 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
168 })
169 }
170
171 async getId (options: OverrideCommandOptions & {
172 uuid: number | string
173 }) {
174 const { uuid } = options
175
176 if (validator.isUUID('' + uuid) === false) return uuid as number
177
178 const { id } = await this.get({ ...options, id: uuid })
179
180 return id
181 }
182
183 async listFiles (options: OverrideCommandOptions & {
184 id: number | string
185 }) {
186 const video = await this.get(options)
187
188 const files = video.files || []
189 const hlsFiles = video.streamingPlaylists[0]?.files || []
190
191 return files.concat(hlsFiles)
192 }
193
194 // ---------------------------------------------------------------------------
195
196 listMyVideos (options: OverrideCommandOptions & {
197 start?: number
198 count?: number
199 sort?: string
200 search?: string
201 isLive?: boolean
202 channelId?: number
203 } = {}) {
204 const path = '/api/v1/users/me/videos'
205
206 return this.getRequestBody<ResultList<Video>>({
207 ...options,
208
209 path,
210 query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
211 implicitToken: true,
212 defaultExpectedStatus: HttpStatusCode.OK_200
213 })
214 }
215
216 // ---------------------------------------------------------------------------
217
218 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
219 const path = '/api/v1/videos'
220
221 const query = this.buildListQuery(options)
222
223 return this.getRequestBody<ResultList<Video>>({
224 ...options,
225
226 path,
227 query: { sort: 'name', ...query },
228 implicitToken: false,
229 defaultExpectedStatus: HttpStatusCode.OK_200
230 })
231 }
232
233 listWithToken (options: OverrideCommandOptions & VideosCommonQuery = {}) {
234 return this.list({
235 ...options,
236
237 token: this.buildCommonRequestToken({ ...options, implicitToken: true })
238 })
239 }
240
241 listByAccount (options: OverrideCommandOptions & VideosCommonQuery & {
242 handle: string
243 }) {
244 const { handle, search } = options
245 const path = '/api/v1/accounts/' + handle + '/videos'
246
247 return this.getRequestBody<ResultList<Video>>({
248 ...options,
249
250 path,
251 query: { search, ...this.buildListQuery(options) },
252 implicitToken: true,
253 defaultExpectedStatus: HttpStatusCode.OK_200
254 })
255 }
256
257 listByChannel (options: OverrideCommandOptions & VideosCommonQuery & {
258 handle: string
259 }) {
260 const { handle } = options
261 const path = '/api/v1/video-channels/' + handle + '/videos'
262
263 return this.getRequestBody<ResultList<Video>>({
264 ...options,
265
266 path,
267 query: this.buildListQuery(options),
268 implicitToken: true,
269 defaultExpectedStatus: HttpStatusCode.OK_200
270 })
271 }
272
273 // ---------------------------------------------------------------------------
274
275 async find (options: OverrideCommandOptions & {
276 name: string
277 }) {
278 const { data } = await this.list(options)
279
280 return data.find(v => v.name === options.name)
281 }
282
283 // ---------------------------------------------------------------------------
284
285 update (options: OverrideCommandOptions & {
286 id: number | string
287 attributes?: VideoEdit
288 }) {
289 const { id, attributes = {} } = options
290 const path = '/api/v1/videos/' + id
291
292 // Upload request
293 if (attributes.thumbnailfile || attributes.previewfile) {
294 const attaches: any = {}
295 if (attributes.thumbnailfile) attaches.thumbnailfile = attributes.thumbnailfile
296 if (attributes.previewfile) attaches.previewfile = attributes.previewfile
297
298 return this.putUploadRequest({
299 ...options,
300
301 path,
302 fields: options.attributes,
303 attaches: {
304 thumbnailfile: attributes.thumbnailfile,
305 previewfile: attributes.previewfile
306 },
307 implicitToken: true,
308 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
309 })
310 }
311
312 return this.putBodyRequest({
313 ...options,
314
315 path,
316 fields: options.attributes,
317 implicitToken: true,
318 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
319 })
320 }
321
322 remove (options: OverrideCommandOptions & {
323 id: number | string
324 }) {
325 const path = '/api/v1/videos/' + options.id
326
327 return unwrapBody(this.deleteRequest({
328 ...options,
329
330 path,
331 implicitToken: true,
332 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
333 }))
334 }
335
336 async removeAll () {
337 const { data } = await this.list()
338
339 for (const v of data) {
340 await this.remove({ id: v.id })
341 }
342 }
343
344 // ---------------------------------------------------------------------------
345
346 async upload (options: OverrideCommandOptions & {
347 attributes?: VideoEdit
348 mode?: 'legacy' | 'resumable' // default legacy
349 } = {}) {
350 const { mode = 'legacy' } = options
351 let defaultChannelId = 1
352
353 try {
354 const { videoChannels } = await this.server.users.getMyInfo({ token: options.token })
355 defaultChannelId = videoChannels[0].id
356 } catch (e) { /* empty */ }
357
358 // Override default attributes
359 const attributes = {
360 name: 'my super video',
361 category: 5,
362 licence: 4,
363 language: 'zh',
364 channelId: defaultChannelId,
365 nsfw: true,
366 waitTranscoding: false,
367 description: 'my super description',
368 support: 'my super support text',
369 tags: [ 'tag' ],
370 privacy: VideoPrivacy.PUBLIC,
371 commentsEnabled: true,
372 downloadEnabled: true,
373 fixture: 'video_short.webm',
374
375 ...options.attributes
376 }
377
378 const created = mode === 'legacy'
379 ? await this.buildLegacyUpload({ ...options, attributes })
380 : await this.buildResumeUpload({ ...options, attributes })
381
382 // Wait torrent generation
383 const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 })
384 if (expectedStatus === HttpStatusCode.OK_200) {
385 let video: VideoDetails
386
387 do {
388 video = await this.getWithToken({ ...options, id: created.uuid })
389
390 await wait(50)
391 } while (!video.files[0].torrentUrl)
392 }
393
394 return created
395 }
396
397 async buildLegacyUpload (options: OverrideCommandOptions & {
398 attributes: VideoEdit
399 }): Promise<VideoCreateResult> {
400 const path = '/api/v1/videos/upload'
401
402 return unwrapBody<{ video: VideoCreateResult }>(this.postUploadRequest({
403 ...options,
404
405 path,
406 fields: this.buildUploadFields(options.attributes),
407 attaches: this.buildUploadAttaches(options.attributes),
408 implicitToken: true,
409 defaultExpectedStatus: HttpStatusCode.OK_200
410 })).then(body => body.video || body as any)
411 }
412
413 async buildResumeUpload (options: OverrideCommandOptions & {
414 attributes: VideoEdit
415 }): Promise<VideoCreateResult> {
416 const { attributes, expectedStatus } = options
417
418 let size = 0
419 let videoFilePath: string
420 let mimetype = 'video/mp4'
421
422 if (attributes.fixture) {
423 videoFilePath = buildAbsoluteFixturePath(attributes.fixture)
424 size = (await stat(videoFilePath)).size
425
426 if (videoFilePath.endsWith('.mkv')) {
427 mimetype = 'video/x-matroska'
428 } else if (videoFilePath.endsWith('.webm')) {
429 mimetype = 'video/webm'
430 }
431 }
432
433 // Do not check status automatically, we'll check it manually
434 const initializeSessionRes = await this.prepareResumableUpload({ ...options, expectedStatus: null, attributes, size, mimetype })
435 const initStatus = initializeSessionRes.status
436
437 if (videoFilePath && initStatus === HttpStatusCode.CREATED_201) {
438 const locationHeader = initializeSessionRes.header['location']
439 expect(locationHeader).to.not.be.undefined
440
441 const pathUploadId = locationHeader.split('?')[1]
442
443 const result = await this.sendResumableChunks({ ...options, pathUploadId, videoFilePath, size })
444
445 if (result.statusCode === HttpStatusCode.OK_200) {
446 await this.endResumableUpload({ ...options, expectedStatus: HttpStatusCode.NO_CONTENT_204, pathUploadId })
447 }
448
449 return result.body?.video || result.body as any
450 }
451
452 const expectedInitStatus = expectedStatus === HttpStatusCode.OK_200
453 ? HttpStatusCode.CREATED_201
454 : expectedStatus
455
456 expect(initStatus).to.equal(expectedInitStatus)
457
458 return initializeSessionRes.body.video || initializeSessionRes.body
459 }
460
461 async prepareResumableUpload (options: OverrideCommandOptions & {
462 attributes: VideoEdit
463 size: number
464 mimetype: string
465
466 originalName?: string
467 lastModified?: number
468 }) {
469 const { attributes, originalName, lastModified, size, mimetype } = options
470
471 const path = '/api/v1/videos/upload-resumable'
472
473 return this.postUploadRequest({
474 ...options,
475
476 path,
477 headers: {
478 'X-Upload-Content-Type': mimetype,
479 'X-Upload-Content-Length': size.toString()
480 },
481 fields: {
482 filename: attributes.fixture,
483 originalName,
484 lastModified,
485
486 ...this.buildUploadFields(options.attributes)
487 },
488
489 // Fixture will be sent later
490 attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
491 implicitToken: true,
492
493 defaultExpectedStatus: null
494 })
495 }
496
497 sendResumableChunks (options: OverrideCommandOptions & {
498 pathUploadId: string
499 videoFilePath: string
500 size: number
501 contentLength?: number
502 contentRangeBuilder?: (start: number, chunk: any) => string
503 }) {
504 const { pathUploadId, videoFilePath, size, contentLength, contentRangeBuilder, expectedStatus = HttpStatusCode.OK_200 } = options
505
506 const path = '/api/v1/videos/upload-resumable'
507 let start = 0
508
509 const token = this.buildCommonRequestToken({ ...options, implicitToken: true })
510 const url = this.server.url
511
512 const readable = createReadStream(videoFilePath, { highWaterMark: 8 * 1024 })
513 return new Promise<GotResponse<{ video: VideoCreateResult }>>((resolve, reject) => {
514 readable.on('data', async function onData (chunk) {
515 readable.pause()
516
517 const headers = {
518 'Authorization': 'Bearer ' + token,
519 'Content-Type': 'application/octet-stream',
520 'Content-Range': contentRangeBuilder
521 ? contentRangeBuilder(start, chunk)
522 : `bytes ${start}-${start + chunk.length - 1}/${size}`,
523 'Content-Length': contentLength ? contentLength + '' : chunk.length + ''
524 }
525
526 const res = await got<{ video: VideoCreateResult }>({
527 url,
528 method: 'put',
529 headers,
530 path: path + '?' + pathUploadId,
531 body: chunk,
532 responseType: 'json',
533 throwHttpErrors: false
534 })
535
536 start += chunk.length
537
538 if (res.statusCode === expectedStatus) {
539 return resolve(res)
540 }
541
542 if (res.statusCode !== HttpStatusCode.PERMANENT_REDIRECT_308) {
543 readable.off('data', onData)
544 return reject(new Error('Incorrect transient behaviour sending intermediary chunks'))
545 }
546
547 readable.resume()
548 })
549 })
550 }
551
552 endResumableUpload (options: OverrideCommandOptions & {
553 pathUploadId: string
554 }) {
555 return this.deleteRequest({
556 ...options,
557
558 path: '/api/v1/videos/upload-resumable',
559 rawQuery: options.pathUploadId,
560 implicitToken: true,
561 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
562 })
563 }
564
565 quickUpload (options: OverrideCommandOptions & {
566 name: string
567 nsfw?: boolean
568 privacy?: VideoPrivacy
569 fixture?: string
570 }) {
571 const attributes: VideoEdit = { name: options.name }
572 if (options.nsfw) attributes.nsfw = options.nsfw
573 if (options.privacy) attributes.privacy = options.privacy
574 if (options.fixture) attributes.fixture = options.fixture
575
576 return this.upload({ ...options, attributes })
577 }
578
579 async randomUpload (options: OverrideCommandOptions & {
580 wait?: boolean // default true
581 additionalParams?: VideoEdit & { prefixName?: string }
582 } = {}) {
583 const { wait = true, additionalParams } = options
584 const prefixName = additionalParams?.prefixName || ''
585 const name = prefixName + buildUUID()
586
587 const attributes = { name, ...additionalParams }
588
589 const result = await this.upload({ ...options, attributes })
590
591 if (wait) await waitJobs([ this.server ])
592
593 return { ...result, name }
594 }
595
596 // ---------------------------------------------------------------------------
597
598 removeHLSFiles (options: OverrideCommandOptions & {
599 videoId: number | string
600 }) {
601 const path = '/api/v1/videos/' + options.videoId + '/hls'
602
603 return this.deleteRequest({
604 ...options,
605
606 path,
607 implicitToken: true,
608 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
609 })
610 }
611
612 removeWebTorrentFiles (options: OverrideCommandOptions & {
613 videoId: number | string
614 }) {
615 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
616
617 return this.deleteRequest({
618 ...options,
619
620 path,
621 implicitToken: true,
622 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
623 })
624 }
625
626 runTranscoding (options: OverrideCommandOptions & {
627 videoId: number | string
628 transcodingType: 'hls' | 'webtorrent'
629 }) {
630 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
631
632 const fields: VideoTranscodingCreate = pick(options, [ 'transcodingType' ])
633
634 return this.postBodyRequest({
635 ...options,
636
637 path,
638 fields,
639 implicitToken: true,
640 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
641 })
642 }
643
644 // ---------------------------------------------------------------------------
645
646 private buildListQuery (options: VideosCommonQuery) {
647 return pick(options, [
648 'start',
649 'count',
650 'sort',
651 'nsfw',
652 'isLive',
653 'categoryOneOf',
654 'licenceOneOf',
655 'languageOneOf',
656 'tagsOneOf',
657 'tagsAllOf',
658 'isLocal',
659 'include',
660 'skipCount'
661 ])
662 }
663
664 private buildUploadFields (attributes: VideoEdit) {
665 return omit(attributes, [ 'fixture', 'thumbnailfile', 'previewfile' ])
666 }
667
668 private buildUploadAttaches (attributes: VideoEdit) {
669 const attaches: { [ name: string ]: string } = {}
670
671 for (const key of [ 'thumbnailfile', 'previewfile' ]) {
672 if (attributes[key]) attaches[key] = buildAbsoluteFixturePath(attributes[key])
673 }
674
675 if (attributes.fixture) attaches.videofile = buildAbsoluteFixturePath(attributes.fixture)
676
677 return attaches
678 }
679}
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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
2
3import { expect } from 'chai'
4import { pathExists, readdir } from 'fs-extra'
5import { basename, join } from 'path'
6import { HttpStatusCode, VideoCaption, VideoDetails } from '@shared/models'
7import { waitJobs } from '../server'
8import { PeerTubeServer } from '../server/server'
9import { VideoEdit } from './videos-command'
10
11async function checkVideoFilesWereRemoved (options: {
12 server: PeerTubeServer
13 video: VideoDetails
14 captions?: VideoCaption[]
15 onlyVideoFiles?: boolean // default false
16}) {
17 const { video, server, captions = [], onlyVideoFiles = false } = options
18
19 const webtorrentFiles = video.files || []
20 const hlsFiles = video.streamingPlaylists[0]?.files || []
21
22 const thumbnailName = basename(video.thumbnailPath)
23 const previewName = basename(video.previewPath)
24
25 const torrentNames = webtorrentFiles.concat(hlsFiles).map(f => basename(f.torrentUrl))
26
27 const captionNames = captions.map(c => basename(c.captionPath))
28
29 const webtorrentFilenames = webtorrentFiles.map(f => basename(f.fileUrl))
30 const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl))
31
32 let directories: { [ directory: string ]: string[] } = {
33 videos: webtorrentFilenames,
34 redundancy: webtorrentFilenames,
35 [join('playlists', 'hls')]: hlsFilenames,
36 [join('redundancy', 'hls')]: hlsFilenames
37 }
38
39 if (onlyVideoFiles !== true) {
40 directories = {
41 ...directories,
42
43 thumbnails: [ thumbnailName ],
44 previews: [ previewName ],
45 torrents: torrentNames,
46 captions: captionNames
47 }
48 }
49
50 for (const directory of Object.keys(directories)) {
51 const directoryPath = server.servers.buildDirectory(directory)
52
53 const directoryExists = await pathExists(directoryPath)
54 if (directoryExists === false) continue
55
56 const existingFiles = await readdir(directoryPath)
57 for (const existingFile of existingFiles) {
58 for (const shouldNotExist of directories[directory]) {
59 expect(existingFile, `File ${existingFile} should not exist in ${directoryPath}`).to.not.contain(shouldNotExist)
60 }
61 }
62 }
63}
64
65async function saveVideoInServers (servers: PeerTubeServer[], uuid: string) {
66 for (const server of servers) {
67 server.store.videoDetails = await server.videos.get({ id: uuid })
68 }
69}
70
71function checkUploadVideoParam (
72 server: PeerTubeServer,
73 token: string,
74 attributes: Partial<VideoEdit>,
75 expectedStatus = HttpStatusCode.OK_200,
76 mode: 'legacy' | 'resumable' = 'legacy'
77) {
78 return mode === 'legacy'
79 ? server.videos.buildLegacyUpload({ token, attributes, expectedStatus })
80 : server.videos.buildResumeUpload({ token, attributes, expectedStatus })
81}
82
83// serverNumber starts from 1
84async function uploadRandomVideoOnServers (
85 servers: PeerTubeServer[],
86 serverNumber: number,
87 additionalParams?: VideoEdit & { prefixName?: string }
88) {
89 const server = servers.find(s => s.serverNumber === serverNumber)
90 const res = await server.videos.randomUpload({ wait: false, additionalParams })
91
92 await waitJobs(servers)
93
94 return res
95}
96
97// ---------------------------------------------------------------------------
98
99export {
100 checkUploadVideoParam,
101 uploadRandomVideoOnServers,
102 checkVideoFilesWereRemoved,
103 saveVideoInServers
104}