From 3a4992633ee62d5edfbb484d9c6bcb3cf158489d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 31 Jul 2023 14:34:36 +0200 Subject: Migrate server to ESM Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports) --- .../server-commands/src/users/accounts-command.ts | 76 ++++ packages/server-commands/src/users/accounts.ts | 15 + .../server-commands/src/users/blocklist-command.ts | 165 +++++++++ packages/server-commands/src/users/index.ts | 10 + .../server-commands/src/users/login-command.ts | 159 +++++++++ packages/server-commands/src/users/login.ts | 19 + .../src/users/notifications-command.ts | 85 +++++ .../src/users/registrations-command.ts | 157 +++++++++ .../src/users/subscriptions-command.ts | 83 +++++ .../src/users/two-factor-command.ts | 92 +++++ .../server-commands/src/users/users-command.ts | 389 +++++++++++++++++++++ 11 files changed, 1250 insertions(+) create mode 100644 packages/server-commands/src/users/accounts-command.ts create mode 100644 packages/server-commands/src/users/accounts.ts create mode 100644 packages/server-commands/src/users/blocklist-command.ts create mode 100644 packages/server-commands/src/users/index.ts create mode 100644 packages/server-commands/src/users/login-command.ts create mode 100644 packages/server-commands/src/users/login.ts create mode 100644 packages/server-commands/src/users/notifications-command.ts create mode 100644 packages/server-commands/src/users/registrations-command.ts create mode 100644 packages/server-commands/src/users/subscriptions-command.ts create mode 100644 packages/server-commands/src/users/two-factor-command.ts create mode 100644 packages/server-commands/src/users/users-command.ts (limited to 'packages/server-commands/src/users') diff --git a/packages/server-commands/src/users/accounts-command.ts b/packages/server-commands/src/users/accounts-command.ts new file mode 100644 index 000000000..fd98b7eea --- /dev/null +++ b/packages/server-commands/src/users/accounts-command.ts @@ -0,0 +1,76 @@ +import { Account, AccountVideoRate, ActorFollow, HttpStatusCode, ResultList, VideoRateType } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class AccountsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + } = {}) { + const { sort = '-createdAt' } = options + const path = '/api/v1/accounts' + + return this.getRequestBody>({ + ...options, + + path, + query: { sort }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + accountName: string + }) { + const path = '/api/v1/accounts/' + options.accountName + + return this.getRequestBody({ + ...options, + + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listRatings (options: OverrideCommandOptions & { + accountName: string + rating?: VideoRateType + }) { + const { rating, accountName } = options + const path = '/api/v1/accounts/' + accountName + '/ratings' + + const query = { rating } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + listFollowers (options: OverrideCommandOptions & { + accountName: string + start?: number + count?: number + sort?: string + search?: string + }) { + const { accountName, start, count, sort, search } = options + const path = '/api/v1/accounts/' + accountName + '/followers' + + const query = { start, count, sort, search } + + return this.getRequestBody>({ + ...options, + + path, + query, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/users/accounts.ts b/packages/server-commands/src/users/accounts.ts new file mode 100644 index 000000000..3b8b9d36a --- /dev/null +++ b/packages/server-commands/src/users/accounts.ts @@ -0,0 +1,15 @@ +import { PeerTubeServer } from '../server/server.js' + +async function setDefaultAccountAvatar (serversArg: PeerTubeServer | PeerTubeServer[], token?: string) { + const servers = Array.isArray(serversArg) + ? serversArg + : [ serversArg ] + + for (const server of servers) { + await server.users.updateMyAvatar({ fixture: 'avatar.png', token }) + } +} + +export { + setDefaultAccountAvatar +} diff --git a/packages/server-commands/src/users/blocklist-command.ts b/packages/server-commands/src/users/blocklist-command.ts new file mode 100644 index 000000000..c77c56131 --- /dev/null +++ b/packages/server-commands/src/users/blocklist-command.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type ListBlocklistOptions = OverrideCommandOptions & { + start: number + count: number + + sort?: string // default -createdAt + + search?: string +} + +export class BlocklistCommand extends AbstractCommand { + + listMyAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listMyServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/users/me/blocklist/servers' + + return this.listBlocklist(options, path) + } + + listServerAccountBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/accounts' + + return this.listBlocklist(options, path) + } + + listServerServerBlocklist (options: ListBlocklistOptions) { + const path = '/api/v1/server/blocklist/servers' + + return this.listBlocklist(options, path) + } + + // --------------------------------------------------------------------------- + + getStatus (options: OverrideCommandOptions & { + accounts?: string[] + hosts?: string[] + }) { + const { accounts, hosts } = options + + const path = '/api/v1/blocklist/status' + + return this.getRequestBody({ + ...options, + + path, + query: { + accounts, + hosts + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + addToMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts' + : '/api/v1/users/me/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + addToServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts' + : '/api/v1/server/blocklist/servers' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + accountName: account, + host: server + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + removeFromMyBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/users/me/blocklist/accounts/' + account + : '/api/v1/users/me/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeFromServerBlocklist (options: OverrideCommandOptions & { + account?: string + server?: string + }) { + const { account, server } = options + + const path = account + ? '/api/v1/server/blocklist/accounts/' + account + : '/api/v1/server/blocklist/servers/' + server + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + private listBlocklist (options: ListBlocklistOptions, path: string) { + const { start, count, search, sort = '-createdAt' } = options + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort, search }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + +} diff --git a/packages/server-commands/src/users/index.ts b/packages/server-commands/src/users/index.ts new file mode 100644 index 000000000..baa048a43 --- /dev/null +++ b/packages/server-commands/src/users/index.ts @@ -0,0 +1,10 @@ +export * from './accounts-command.js' +export * from './accounts.js' +export * from './blocklist-command.js' +export * from './login.js' +export * from './login-command.js' +export * from './notifications-command.js' +export * from './registrations-command.js' +export * from './subscriptions-command.js' +export * from './two-factor-command.js' +export * from './users-command.js' diff --git a/packages/server-commands/src/users/login-command.ts b/packages/server-commands/src/users/login-command.ts new file mode 100644 index 000000000..92d123dfc --- /dev/null +++ b/packages/server-commands/src/users/login-command.ts @@ -0,0 +1,159 @@ +import { HttpStatusCode, PeerTubeProblemDocument } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +type LoginOptions = OverrideCommandOptions & { + client?: { id?: string, secret?: string } + user?: { username: string, password?: string } + otpToken?: string +} + +export class LoginCommand extends AbstractCommand { + + async login (options: LoginOptions = {}) { + const res = await this._login(options) + + return this.unwrapLoginBody(res.body) + } + + async loginAndGetResponse (options: LoginOptions = {}) { + const res = await this._login(options) + + return { + res, + body: this.unwrapLoginBody(res.body) + } + } + + getAccessToken (arg1?: { username: string, password?: string }): Promise + getAccessToken (arg1: string, password?: string): Promise + async getAccessToken (arg1?: { username: string, password?: string } | string, password?: string) { + let user: { username: string, password?: string } + + if (!arg1) user = this.server.store.user + else if (typeof arg1 === 'object') user = arg1 + else user = { username: arg1, password } + + try { + const body = await this.login({ user }) + + return body.access_token + } catch (err) { + throw new Error(`Cannot authenticate. Please check your username/password. (${err})`) + } + } + + loginUsingExternalToken (options: OverrideCommandOptions & { + username: string + externalAuthToken: string + }) { + const { username, externalAuthToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + username, + response_type: 'code', + grant_type: 'password', + scope: 'upload', + externalAuthToken + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + logout (options: OverrideCommandOptions & { + token: string + }) { + const path = '/api/v1/users/revoke-token' + + return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + refreshToken (options: OverrideCommandOptions & { + refreshToken: string + }) { + const path = '/api/v1/users/token' + + const body = { + client_id: this.server.store.client.id, + client_secret: this.server.store.client.secret, + refresh_token: options.refreshToken, + response_type: 'code', + grant_type: 'refresh_token' + } + + return this.postBodyRequest({ + ...options, + + path, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getClient (options: OverrideCommandOptions = {}) { + const path = '/api/v1/oauth-clients/local' + + return this.getRequestBody<{ client_id: string, client_secret: string }>({ + ...options, + + path, + host: this.server.host, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private _login (options: LoginOptions) { + const { client = this.server.store.client, user = this.server.store.user, otpToken } = options + const path = '/api/v1/users/token' + + const body = { + client_id: client.id, + client_secret: client.secret, + username: user.username, + password: user.password ?? 'password', + response_type: 'code', + grant_type: 'password', + scope: 'upload' + } + + const headers = otpToken + ? { 'x-peertube-otp': otpToken } + : {} + + return this.postBodyRequest({ + ...options, + + path, + headers, + requestType: 'form', + fields: body, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + private unwrapLoginBody (body: any) { + return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument + } +} diff --git a/packages/server-commands/src/users/login.ts b/packages/server-commands/src/users/login.ts new file mode 100644 index 000000000..c48c42c72 --- /dev/null +++ b/packages/server-commands/src/users/login.ts @@ -0,0 +1,19 @@ +import { PeerTubeServer } from '../server/server.js' + +function setAccessTokensToServers (servers: PeerTubeServer[]) { + const tasks: Promise[] = [] + + for (const server of servers) { + const p = server.login.getAccessToken() + .then(t => { server.accessToken = t }) + tasks.push(p) + } + + return Promise.all(tasks) +} + +// --------------------------------------------------------------------------- + +export { + setAccessTokensToServers +} diff --git a/packages/server-commands/src/users/notifications-command.ts b/packages/server-commands/src/users/notifications-command.ts new file mode 100644 index 000000000..d90d56900 --- /dev/null +++ b/packages/server-commands/src/users/notifications-command.ts @@ -0,0 +1,85 @@ +import { HttpStatusCode, ResultList, UserNotification, UserNotificationSetting } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class NotificationsCommand extends AbstractCommand { + + updateMySettings (options: OverrideCommandOptions & { + settings: UserNotificationSetting + }) { + const path = '/api/v1/users/me/notification-settings' + + return this.putBodyRequest({ + ...options, + + path, + fields: options.settings, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + unread?: boolean + sort?: string + }) { + const { start, count, unread, sort = '-createdAt' } = options + const path = '/api/v1/users/me/notifications' + + return this.getRequestBody>({ + ...options, + + path, + query: { + start, + count, + sort, + unread + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + markAsRead (options: OverrideCommandOptions & { + ids: number[] + }) { + const { ids } = options + const path = '/api/v1/users/me/notifications/read' + + return this.postBodyRequest({ + ...options, + + path, + fields: { ids }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + markAsReadAll (options: OverrideCommandOptions) { + const path = '/api/v1/users/me/notifications/read-all' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async getLatest (options: OverrideCommandOptions = {}) { + const { total, data } = await this.list({ + ...options, + start: 0, + count: 1, + sort: '-createdAt' + }) + + if (total === 0) return undefined + + return data[0] + } +} diff --git a/packages/server-commands/src/users/registrations-command.ts b/packages/server-commands/src/users/registrations-command.ts new file mode 100644 index 000000000..2111fbd39 --- /dev/null +++ b/packages/server-commands/src/users/registrations-command.ts @@ -0,0 +1,157 @@ +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + ResultList, + UserRegistration, + UserRegistrationRequest, + UserRegistrationUpdateState +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class RegistrationsCommand extends AbstractCommand { + + register (options: OverrideCommandOptions & Partial & Pick) { + const { password = 'password', email = options.username + '@example.com' } = options + const path = '/api/v1/users/register' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'username', 'displayName', 'channel' ]), + + password, + email + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + requestRegistration ( + options: OverrideCommandOptions & Partial & Pick + ) { + const { password = 'password', email = options.username + '@example.com' } = options + const path = '/api/v1/users/registrations/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { + ...pick(options, [ 'username', 'displayName', 'channel', 'registrationReason' ]), + + password, + email + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + // --------------------------------------------------------------------------- + + accept (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reject (options: OverrideCommandOptions & { id: number } & UserRegistrationUpdateState) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + fields: pick(options, [ 'moderationResponse', 'preventEmailDelivery' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + delete (options: OverrideCommandOptions & { + id: number + }) { + const { id } = options + const path = '/api/v1/users/registrations/' + id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + } = {}) { + const path = '/api/v1/users/registrations' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/registrations/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + registrationId: number + verificationString: string + }) { + const { registrationId, verificationString } = options + const path = '/api/v1/users/registrations/' + registrationId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/users/subscriptions-command.ts b/packages/server-commands/src/users/subscriptions-command.ts new file mode 100644 index 000000000..52a1f0e51 --- /dev/null +++ b/packages/server-commands/src/users/subscriptions-command.ts @@ -0,0 +1,83 @@ +import { HttpStatusCode, ResultList, VideoChannel } from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class SubscriptionsCommand extends AbstractCommand { + + add (options: OverrideCommandOptions & { + targetUri: string + }) { + const path = '/api/v1/users/me/subscriptions' + + return this.postBodyRequest({ + ...options, + + path, + fields: { uri: options.targetUri }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + list (options: OverrideCommandOptions & { + sort?: string // default -createdAt + search?: string + } = {}) { + const { sort = '-createdAt', search } = options + const path = '/api/v1/users/me/subscriptions' + + return this.getRequestBody>({ + ...options, + + path, + query: { + sort, + search + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + get (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + uri: string + }) { + const path = '/api/v1/users/me/subscriptions/' + options.uri + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + exist (options: OverrideCommandOptions & { + uris: string[] + }) { + const path = '/api/v1/users/me/subscriptions/exist' + + return this.getRequestBody<{ [id: string ]: boolean }>({ + ...options, + + path, + query: { 'uris[]': options.uris }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } +} diff --git a/packages/server-commands/src/users/two-factor-command.ts b/packages/server-commands/src/users/two-factor-command.ts new file mode 100644 index 000000000..cf3d6cb68 --- /dev/null +++ b/packages/server-commands/src/users/two-factor-command.ts @@ -0,0 +1,92 @@ +import { TOTP } from 'otpauth' +import { HttpStatusCode, TwoFactorEnableResult } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class TwoFactorCommand extends AbstractCommand { + + static buildOTP (options: { + secret: string + }) { + const { secret } = options + + return new TOTP({ + issuer: 'PeerTube', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret + }) + } + + request (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { currentPassword, userId } = options + + const path = '/api/v1/users/' + userId + '/two-factor/request' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + confirmRequest (options: OverrideCommandOptions & { + userId: number + requestToken: string + otpToken: string + }) { + const { userId, requestToken, otpToken } = options + + const path = '/api/v1/users/' + userId + '/two-factor/confirm-request' + + return this.postBodyRequest({ + ...options, + + path, + fields: { requestToken, otpToken }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + disable (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { userId, currentPassword } = options + const path = '/api/v1/users/' + userId + '/two-factor/disable' + + return this.postBodyRequest({ + ...options, + + path, + fields: { currentPassword }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + async requestAndConfirm (options: OverrideCommandOptions & { + userId: number + currentPassword?: string + }) { + const { userId, currentPassword } = options + + const { otpRequest } = await this.request({ userId, currentPassword }) + + await this.confirmRequest({ + userId, + requestToken: otpRequest.requestToken, + otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate() + }) + + return otpRequest + } +} diff --git a/packages/server-commands/src/users/users-command.ts b/packages/server-commands/src/users/users-command.ts new file mode 100644 index 000000000..d3b11939e --- /dev/null +++ b/packages/server-commands/src/users/users-command.ts @@ -0,0 +1,389 @@ +import { omit, pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + MyUser, + ResultList, + ScopedToken, + User, + UserAdminFlagType, + UserCreateResult, + UserRole, + UserRoleType, + UserUpdate, + UserUpdateMe, + UserVideoQuota, + UserVideoRate +} from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class UsersCommand extends AbstractCommand { + + askResetPassword (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + resetPassword (options: OverrideCommandOptions & { + userId: number + verificationString: string + password: string + }) { + const { userId, verificationString, password } = options + const path = '/api/v1/users/' + userId + '/reset-password' + + return this.postBodyRequest({ + ...options, + + path, + fields: { password, verificationString }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + askSendVerifyEmail (options: OverrideCommandOptions & { + email: string + }) { + const { email } = options + const path = '/api/v1/users/ask-send-verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { email }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + verifyEmail (options: OverrideCommandOptions & { + userId: number + verificationString: string + isPendingEmail?: boolean // default false + }) { + const { userId, verificationString, isPendingEmail = false } = options + const path = '/api/v1/users/' + userId + '/verify-email' + + return this.postBodyRequest({ + ...options, + + path, + fields: { + verificationString, + isPendingEmail + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + banUser (options: OverrideCommandOptions & { + userId: number + reason?: string + }) { + const { userId, reason } = options + const path = '/api/v1/users' + '/' + userId + '/block' + + return this.postBodyRequest({ + ...options, + + path, + fields: { reason }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + unbanUser (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users' + '/' + userId + '/unblock' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + + getMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + renewMyScopedTokens (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/scoped-tokens' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + create (options: OverrideCommandOptions & { + username: string + password?: string + videoQuota?: number + videoQuotaDaily?: number + role?: UserRoleType + adminFlags?: UserAdminFlagType + }) { + const { + username, + adminFlags, + password = 'password', + videoQuota, + videoQuotaDaily, + role = UserRole.USER + } = options + + const path = '/api/v1/users' + + return unwrapBody<{ user: UserCreateResult }>(this.postBodyRequest({ + ...options, + + path, + fields: { + username, + password, + role, + adminFlags, + email: username + '@example.com', + videoQuota, + videoQuotaDaily + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })).then(res => res.user) + } + + async generate (username: string, role?: UserRoleType) { + const password = 'password' + const user = await this.create({ username, password, role }) + + const token = await this.server.login.getAccessToken({ username, password }) + + const me = await this.getMyInfo({ token }) + + return { + token, + userId: user.id, + userChannelId: me.videoChannels[0].id, + userChannelName: me.videoChannels[0].name, + password + } + } + + async generateUserAndToken (username: string, role?: UserRoleType) { + const password = 'password' + await this.create({ username, password, role }) + + return this.server.login.getAccessToken({ username, password }) + } + + // --------------------------------------------------------------------------- + + getMyInfo (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyQuotaUsed (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me/video-quota-used' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + getMyRating (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/users/me/videos/' + videoId + '/rating' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + deleteMe (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMe (options: OverrideCommandOptions & UserUpdateMe) { + const path = '/api/v1/users/me' + + const toSend: UserUpdateMe = omit(options, [ 'expectedStatus', 'token' ]) + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + updateMyAvatar (options: OverrideCommandOptions & { + fixture: string + }) { + const { fixture } = options + const path = '/api/v1/users/me/avatar/pick' + + return this.updateImageRequest({ + ...options, + + path, + fixture, + fieldname: 'avatarfile', + + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + + get (options: OverrideCommandOptions & { + userId: number + withStats?: boolean // default false + }) { + const { userId, withStats } = options + const path = '/api/v1/users/' + userId + + return this.getRequestBody({ + ...options, + + path, + query: { withStats }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + list (options: OverrideCommandOptions & { + start?: number + count?: number + sort?: string + search?: string + blocked?: boolean + } = {}) { + const path = '/api/v1/users' + + return this.getRequestBody>({ + ...options, + + path, + query: pick(options, [ 'start', 'count', 'sort', 'search', 'blocked' ]), + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + remove (options: OverrideCommandOptions & { + userId: number + }) { + const { userId } = options + const path = '/api/v1/users/' + userId + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + update (options: OverrideCommandOptions & { + userId: number + email?: string + emailVerified?: boolean + videoQuota?: number + videoQuotaDaily?: number + password?: string + adminFlags?: UserAdminFlagType + pluginAuth?: string + role?: UserRoleType + }) { + const path = '/api/v1/users/' + options.userId + + const toSend: UserUpdate = {} + if (options.password !== undefined && options.password !== null) toSend.password = options.password + if (options.email !== undefined && options.email !== null) toSend.email = options.email + if (options.emailVerified !== undefined && options.emailVerified !== null) toSend.emailVerified = options.emailVerified + if (options.videoQuota !== undefined && options.videoQuota !== null) toSend.videoQuota = options.videoQuota + if (options.videoQuotaDaily !== undefined && options.videoQuotaDaily !== null) toSend.videoQuotaDaily = options.videoQuotaDaily + if (options.role !== undefined && options.role !== null) toSend.role = options.role + if (options.adminFlags !== undefined && options.adminFlags !== null) toSend.adminFlags = options.adminFlags + if (options.pluginAuth !== undefined) toSend.pluginAuth = options.pluginAuth + + return this.putBodyRequest({ + ...options, + + path, + fields: toSend, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} -- cgit v1.2.3