ENABLE_OBJECT_STORAGE_TESTS: true
OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
+ YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v3
/src/locale/target/server_*.xml
/e2e/local.log
/e2e/browserstack.err
+/e2e/screenshots
/src/standalone/player/build
/src/standalone/player/dist
-import { getCheckbox, go } from '../utils'
+import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
export class AdminConfigPage {
'basic-configuration': 'APPEARANCE',
'instance-information': 'INSTANCE'
}
-
await go('/admin/config/edit-custom#' + tab)
await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed()
return $('#instanceCustomHomepageContent').setValue(newValue)
}
- async toggleSignup () {
+ async toggleSignup (enabled: boolean) {
+ if (await isCheckboxSelected('signupEnabled') === enabled) return
+
const checkbox = await getCheckbox('signupEnabled')
await checkbox.waitForClickable()
await checkbox.click()
}
+ async toggleSignupApproval (required: boolean) {
+ if (await isCheckboxSelected('signupRequiresApproval') === required) return
+
+ const checkbox = await getCheckbox('signupRequiresApproval')
+
+ await checkbox.waitForClickable()
+ await checkbox.click()
+ }
+
+ async toggleSignupEmailVerification (required: boolean) {
+ if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
+
+ const checkbox = await getCheckbox('signupRequiresEmailVerification')
+
+ await checkbox.waitForClickable()
+ await checkbox.click()
+ }
+
async save () {
const button = $('input[type=submit]')
await button.waitForClickable()
await button.click()
+
+ await browserSleep(1000)
}
}
--- /dev/null
+import { browserSleep, findParentElement, go } from '../utils'
+
+export class AdminRegistrationPage {
+
+ async navigateToRegistratonsList () {
+ await go('/admin/moderation/registrations/list')
+
+ await $('my-registration-list').waitForDisplayed()
+ }
+
+ async accept (username: string, moderationResponse: string) {
+ const usernameEl = await $('*=' + username)
+ await usernameEl.waitForDisplayed()
+
+ const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
+
+ await tr.$('.action-cell .dropdown-root').click()
+
+ const accept = await $('span*=Accept this registration')
+ await accept.waitForClickable()
+ await accept.click()
+
+ const moderationResponseTextarea = await $('#moderationResponse')
+ await moderationResponseTextarea.waitForDisplayed()
+
+ await moderationResponseTextarea.setValue(moderationResponse)
+
+ const submitButton = $('.modal-footer input[type=submit]')
+ await submitButton.waitForClickable()
+ await submitButton.click()
+
+ await browserSleep(1000)
+ }
+
+}
}
- async login (username: string, password: string, url = '/login') {
+ async login (options: {
+ username: string
+ password: string
+ displayName?: string
+ url?: string
+ }) {
+ const { username, password, url = '/login', displayName = username } = options
+
await go(url)
await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
await menuToggle.click()
- await this.ensureIsLoggedInAs(username)
+ await this.ensureIsLoggedInAs(displayName)
await menuToggle.click()
} else {
- await this.ensureIsLoggedInAs(username)
+ await this.ensureIsLoggedInAs(displayName)
}
}
+ async getLoginError (username: string, password: string) {
+ await go('/login')
+
+ await $('input#username').setValue(username)
+ await $('input#password').setValue(password)
+
+ await browser.pause(1000)
+
+ await $('form input[type=submit]').click()
+
+ return $('.alert-danger').getText()
+ }
+
async loginAsRootUser () {
- return this.login('root', 'test' + this.getSuffix())
+ return this.login({ username: 'root', password: 'test' + this.getSuffix() })
}
loginOnPeerTube2 () {
- return this.login('e2e', process.env.PEERTUBE2_E2E_PASSWORD, 'https://peertube2.cpy.re/login')
+ return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
}
async logout () {
- const loggedInMore = $('.logged-in-more')
+ const loggedInDropdown = $('.logged-in-more .logged-in-info')
- await loggedInMore.waitForClickable()
- await loggedInMore.click()
+ await loggedInDropdown.waitForClickable()
+ await loggedInDropdown.click()
const logout = $('.dropdown-item*=Log out')
return terms.click()
}
+ async getEndMessage () {
+ const alert = $('.pt-alert-primary')
+ await alert.waitForDisplayed()
+
+ return alert.getText()
+ }
+
+ async fillRegistrationReason (reason: string) {
+ await $('#registrationReason').setValue(reason)
+ }
+
async fillAccountStep (options: {
- displayName: string
username: string
- email: string
- password: string
+ password?: string
+ displayName?: string
+ email?: string
}) {
- if (options.displayName) {
- await $('#displayName').setValue(options.displayName)
- }
-
- if (options.username) {
- await $('#username').setValue(options.username)
- }
+ await $('#displayName').setValue(options.displayName || `${options.username} display name`)
- if (options.email) {
- // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
- await $('#email').scrollIntoView(false)
- await $('#email').waitForClickable()
- await $('#email').setValue(options.email)
- }
+ await $('#username').setValue(options.username)
+ await $('#password').setValue(options.password || 'password')
- if (options.password) {
- await $('#password').setValue(options.password)
- }
+ // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
+ await $('#email').scrollIntoView(false)
+ await $('#email').waitForClickable()
+ await $('#email').setValue(options.email || `${options.username}@example.com`)
}
async fillChannelStep (options: {
- displayName: string
name: string
+ displayName?: string
}) {
- if (options.displayName) {
- await $('#displayName').setValue(options.displayName)
- }
-
- if (options.name) {
- await $('#name').setValue(options.name)
- }
+ await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
+ await $('#name').setValue(options.name)
}
}
import { AdminConfigPage } from '../po/admin-config.po'
+import { AdminRegistrationPage } from '../po/admin-registration.po'
import { LoginPage } from '../po/login.po'
import { SignupPage } from '../po/signup.po'
-import { isMobileDevice, waitServerUp } from '../utils'
+import { browserSleep, getVerificationLink, go, findEmailTo, isMobileDevice, MockSMTPServer, waitServerUp } from '../utils'
+
+function checkEndMessage (options: {
+ message: string
+ requiresEmailVerification: boolean
+ requiresApproval: boolean
+ afterEmailVerification: boolean
+}) {
+ const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
+
+ {
+ const created = 'account has been created'
+ const request = 'account request has been sent'
+
+ if (requiresApproval) {
+ expect(message).toContain(request)
+ expect(message).not.toContain(created)
+ } else {
+ expect(message).not.toContain(request)
+ expect(message).toContain(created)
+ }
+ }
+
+ {
+ const checkEmail = 'Check your emails'
+
+ if (requiresEmailVerification) {
+ expect(message).toContain(checkEmail)
+ } else {
+ expect(message).not.toContain(checkEmail)
+
+ const moderatorsApproval = 'moderator will check your registration request'
+ if (requiresApproval) {
+ expect(message).toContain(moderatorsApproval)
+ } else {
+ expect(message).not.toContain(moderatorsApproval)
+ }
+ }
+ }
+
+ {
+ const emailVerified = 'email has been verified'
+
+ if (afterEmailVerification) {
+ expect(message).toContain(emailVerified)
+ } else {
+ expect(message).not.toContain(emailVerified)
+ }
+ }
+}
describe('Signup', () => {
let loginPage: LoginPage
let adminConfigPage: AdminConfigPage
let signupPage: SignupPage
+ let adminRegistrationPage: AdminRegistrationPage
+
+ async function prepareSignup (options: {
+ enabled: boolean
+ requiresApproval?: boolean
+ requiresEmailVerification?: boolean
+ }) {
+ await loginPage.loginAsRootUser()
+
+ await adminConfigPage.navigateTo('basic-configuration')
+ await adminConfigPage.toggleSignup(options.enabled)
+
+ if (options.enabled) {
+ if (options.requiresApproval !== undefined) {
+ await adminConfigPage.toggleSignupApproval(options.requiresApproval)
+ }
+
+ if (options.requiresEmailVerification !== undefined) {
+ await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
+ }
+ }
+
+ await adminConfigPage.save()
+
+ await loginPage.logout()
+ await browser.refresh()
+ }
before(async () => {
await waitServerUp()
loginPage = new LoginPage(isMobileDevice())
adminConfigPage = new AdminConfigPage()
signupPage = new SignupPage()
+ adminRegistrationPage = new AdminRegistrationPage()
await browser.maximizeWindow()
})
- it('Should disable signup', async () => {
- await loginPage.loginAsRootUser()
+ describe('Signup disabled', function () {
+ it('Should disable signup', async () => {
+ await prepareSignup({ enabled: false })
- await adminConfigPage.navigateTo('basic-configuration')
- await adminConfigPage.toggleSignup()
+ await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
+ })
+ })
- await adminConfigPage.save()
+ describe('Email verification disabled', function () {
- await loginPage.logout()
- await browser.refresh()
+ describe('Direct registration', function () {
- expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
- })
+ it('Should enable signup without approval', async () => {
+ await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
- it('Should enable signup', async () => {
- await loginPage.loginAsRootUser()
+ await signupPage.getRegisterMenuButton().waitForDisplayed()
+ })
- await adminConfigPage.navigateTo('basic-configuration')
- await adminConfigPage.toggleSignup()
+ it('Should go on signup page', async function () {
+ await signupPage.clickOnRegisterInMenu()
+ })
- await adminConfigPage.save()
+ it('Should validate the first step (about page)', async function () {
+ await signupPage.validateStep()
+ })
- await loginPage.logout()
- await browser.refresh()
+ it('Should validate the second step (terms)', async function () {
+ await signupPage.checkTerms()
+ await signupPage.validateStep()
+ })
- expect(signupPage.getRegisterMenuButton()).toBeDisplayed()
- })
+ it('Should validate the third step (account)', async function () {
+ await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
- it('Should go on signup page', async function () {
- await signupPage.clickOnRegisterInMenu()
- })
+ await signupPage.validateStep()
+ })
- it('Should validate the first step (about page)', async function () {
- await signupPage.validateStep()
- })
+ it('Should validate the third step (channel)', async function () {
+ await signupPage.fillChannelStep({ name: 'user_1_channel' })
- it('Should validate the second step (terms)', async function () {
- await signupPage.checkTerms()
- await signupPage.validateStep()
- })
+ await signupPage.validateStep()
+ })
+
+ it('Should be logged in', async function () {
+ await loginPage.ensureIsLoggedInAs('user_1_dn')
+ })
+
+ it('Should have a valid end message', async function () {
+ const message = await signupPage.getEndMessage()
+
+ checkEndMessage({
+ message,
+ requiresEmailVerification: false,
+ requiresApproval: false,
+ afterEmailVerification: false
+ })
- it('Should validate the third step (account)', async function () {
- await signupPage.fillAccountStep({
- displayName: 'user 1',
- username: 'user_1',
- email: 'user_1@example.com',
- password: 'my_super_password'
+ await browser.saveScreenshot('./screenshots/direct-without-email.png')
+
+ await loginPage.logout()
+ })
})
- await signupPage.validateStep()
+ describe('Registration with approval', function () {
+
+ it('Should enable signup with approval', async () => {
+ await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
+
+ await signupPage.getRegisterMenuButton().waitForDisplayed()
+ })
+
+ it('Should go on signup page', async function () {
+ await signupPage.clickOnRegisterInMenu()
+ })
+
+ it('Should validate the first step (about page)', async function () {
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the second step (terms)', async function () {
+ await signupPage.checkTerms()
+ await signupPage.fillRegistrationReason('my super reason')
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the third step (account)', async function () {
+ await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the third step (channel)', async function () {
+ await signupPage.fillChannelStep({ name: 'user_2_channel' })
+ await signupPage.validateStep()
+ })
+
+ it('Should have a valid end message', async function () {
+ const message = await signupPage.getEndMessage()
+
+ checkEndMessage({
+ message,
+ requiresEmailVerification: false,
+ requiresApproval: true,
+ afterEmailVerification: false
+ })
+
+ await browser.saveScreenshot('./screenshots/request-without-email.png')
+ })
+
+ it('Should display a message when trying to login with this account', async function () {
+ const error = await loginPage.getLoginError('user_2', 'password')
+
+ expect(error).toContain('awaiting approval')
+ })
+
+ it('Should accept the registration', async function () {
+ await loginPage.loginAsRootUser()
+
+ await adminRegistrationPage.navigateToRegistratonsList()
+ await adminRegistrationPage.accept('user_2', 'moderation response')
+
+ await loginPage.logout()
+ })
+
+ it('Should be able to login with this new account', async function () {
+ await loginPage.login({ username: 'user_2', password: 'password', displayName: 'user_2 display name' })
+
+ await loginPage.logout()
+ })
+ })
})
- it('Should validate the third step (channel)', async function () {
- await signupPage.fillChannelStep({
- displayName: 'user 1 channel',
- name: 'user_1_channel'
+ describe('Email verification enabled', function () {
+ const emails: any[] = []
+ let emailPort: number
+
+ before(async () => {
+ // FIXME: typings are wrong, get returns a promise
+ emailPort = await browser.sharedStore.get('emailPort') as unknown as number
+
+ MockSMTPServer.Instance.collectEmails(emailPort, emails)
})
- await signupPage.validateStep()
- })
+ describe('Direct registration', function () {
+
+ it('Should enable signup without approval', async () => {
+ await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
+
+ await signupPage.getRegisterMenuButton().waitForDisplayed()
+ })
+
+ it('Should go on signup page', async function () {
+ await signupPage.clickOnRegisterInMenu()
+ })
+
+ it('Should validate the first step (about page)', async function () {
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the second step (terms)', async function () {
+ await signupPage.checkTerms()
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the third step (account)', async function () {
+ await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
+
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the third step (channel)', async function () {
+ await signupPage.fillChannelStep({ name: 'user_3_channel' })
+
+ await signupPage.validateStep()
+ })
+
+ it('Should have a valid end message', async function () {
+ const message = await signupPage.getEndMessage()
+
+ checkEndMessage({
+ message,
+ requiresEmailVerification: true,
+ requiresApproval: false,
+ afterEmailVerification: false
+ })
+
+ await browser.saveScreenshot('./screenshots/direct-with-email.png')
+ })
+
+ it('Should validate the email', async function () {
+ let email: { text: string }
+
+ while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
+ await browserSleep(100)
+ }
+
+ await go(getVerificationLink(email))
+
+ const message = await signupPage.getEndMessage()
+
+ checkEndMessage({
+ message,
+ requiresEmailVerification: false,
+ requiresApproval: false,
+ afterEmailVerification: true
+ })
- it('Should be logged in', async function () {
- await loginPage.ensureIsLoggedInAs('user 1')
+ await browser.saveScreenshot('./screenshots/direct-after-email.png')
+ })
+ })
+
+ describe('Registration with approval', function () {
+
+ it('Should enable signup without approval', async () => {
+ await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
+
+ await signupPage.getRegisterMenuButton().waitForDisplayed()
+ })
+
+ it('Should go on signup page', async function () {
+ await signupPage.clickOnRegisterInMenu()
+ })
+
+ it('Should validate the first step (about page)', async function () {
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the second step (terms)', async function () {
+ await signupPage.checkTerms()
+ await signupPage.fillRegistrationReason('my super reason 2')
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the third step (account)', async function () {
+ await signupPage.fillAccountStep({
+ username: 'user_4',
+ displayName: 'user_4 display name',
+ email: 'user_4@example.com',
+ password: 'password'
+ })
+ await signupPage.validateStep()
+ })
+
+ it('Should validate the third step (channel)', async function () {
+ await signupPage.fillChannelStep({ name: 'user_4_channel' })
+ await signupPage.validateStep()
+ })
+
+ it('Should have a valid end message', async function () {
+ const message = await signupPage.getEndMessage()
+
+ checkEndMessage({
+ message,
+ requiresEmailVerification: true,
+ requiresApproval: true,
+ afterEmailVerification: false
+ })
+
+ await browser.saveScreenshot('./screenshots/request-with-email.png')
+ })
+
+ it('Should display a message when trying to login with this account', async function () {
+ const error = await loginPage.getLoginError('user_4', 'password')
+
+ expect(error).toContain('awaiting approval')
+ })
+
+ it('Should accept the registration', async function () {
+ await loginPage.loginAsRootUser()
+
+ await adminRegistrationPage.navigateToRegistratonsList()
+ await adminRegistrationPage.accept('user_4', 'moderation response 2')
+
+ await loginPage.logout()
+ })
+
+ it('Should validate the email', async function () {
+ let email: { text: string }
+
+ while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
+ await browserSleep(100)
+ }
+
+ await go(getVerificationLink(email))
+
+ const message = await signupPage.getEndMessage()
+
+ checkEndMessage({
+ message,
+ requiresEmailVerification: false,
+ requiresApproval: true,
+ afterEmailVerification: true
+ })
+
+ await browser.saveScreenshot('./screenshots/request-after-email.png')
+ })
+ })
+
+ before(() => {
+ MockSMTPServer.Instance.kill()
+ })
})
})
return input.parentElement()
}
+function isCheckboxSelected (name: string) {
+ return $(`input[id=${name}]`).isSelected()
+}
+
async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
return option.click()
}
+async function findParentElement (
+ el: WebdriverIO.Element,
+ finder: (el: WebdriverIO.Element) => Promise<boolean>
+) {
+ if (await finder(el) === true) return el
+
+ return findParentElement(await el.parentElement(), finder)
+}
+
export {
getCheckbox,
- selectCustomSelect
+ isCheckboxSelected,
+ selectCustomSelect,
+ findParentElement
}
--- /dev/null
+function getVerificationLink (email: { text: string }) {
+ const { text } = email
+
+ const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
+ const matched = text.matchAll(regexp)
+
+ if (!matched) throw new Error('Could not find verification link in email')
+
+ for (const match of matched) {
+ const link = match.groups.link
+
+ if (link.includes('/verify-account/')) return link
+ }
+
+ throw new Error('Could not find /verify-account/ link')
+}
+
+function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
+ for (const email of emails) {
+ for (const { address } of email.to) {
+ if (address === to) return email
+ }
+ }
+
+ return undefined
+}
+
+export {
+ getVerificationLink,
+ findEmailTo
+}
import { ChildProcessWithoutNullStreams } from 'child_process'
import { basename } from 'path'
import { runCommand, runServer } from './server'
+import { setValue } from '@wdio/shared-store-service'
-let appInstance: string
+let appInstance: number
let app: ChildProcessWithoutNullStreams
+let emailPort: number
+
async function beforeLocalSuite (suite: any) {
const config = buildConfig(suite.file)
app = undefined
}
-function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
- appInstance = capabilities['browserName'] === 'chrome' ? '1' : '2'
+async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
+ appInstance = capabilities['browserName'] === 'chrome'
+ ? 1
+ : 2
+
+ emailPort = 1025 + appInstance
+
config.baseUrl = 'http://localhost:900' + appInstance
+
+ await setValue('emailPort', emailPort)
}
async function onBrowserStackPrepare () {
- const appInstance = '1'
+ const appInstance = 1
await runCommand('npm run clean:server:test -- ' + appInstance)
app = runServer(appInstance)
if (filename === 'signup.e2e-spec.ts') {
return {
signup: {
- enabled: true
+ limit: -1
+ },
+ smtp: {
+ hostname: '127.0.0.1',
+ port: emailPort
}
}
}
export * from './common'
export * from './elements'
+export * from './email'
export * from './hooks'
+export * from './mock-smtp'
export * from './server'
export * from './urls'
--- /dev/null
+import { ChildProcess } from 'child_process'
+import MailDev from '@peertube/maildev'
+
+class MockSMTPServer {
+
+ private static instance: MockSMTPServer
+ private started = false
+ private emailChildProcess: ChildProcess
+ private emails: object[]
+
+ collectEmails (port: number, emailsCollection: object[]) {
+ return new Promise<number>((res, rej) => {
+ this.emails = emailsCollection
+
+ if (this.started) {
+ return res(undefined)
+ }
+
+ const maildev = new MailDev({
+ ip: '127.0.0.1',
+ smtp: port,
+ disableWeb: true,
+ silent: true
+ })
+
+ maildev.on('new', email => {
+ this.emails.push(email)
+ })
+
+ maildev.listen(err => {
+ if (err) return rej(err)
+
+ this.started = true
+
+ return res(port)
+ })
+ })
+ }
+
+ kill () {
+ if (!this.emailChildProcess) return
+
+ process.kill(this.emailChildProcess.pid)
+
+ this.emailChildProcess = null
+ MockSMTPServer.instance = null
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ MockSMTPServer
+}
import { exec, spawn } from 'child_process'
import { join, resolve } from 'path'
-function runServer (appInstance: string, config: any = {}) {
+function runServer (appInstance: number, config: any = {}) {
const env = Object.create(process.env)
env['NODE_ENV'] = 'test'
- env['NODE_APP_INSTANCE'] = appInstance
+ env['NODE_APP_INSTANCE'] = appInstance + ''
env['NODE_CONFIG'] = JSON.stringify({
rates_limit: {
// }
],
- services: [ 'chromedriver', 'geckodriver' ],
+ services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite,
}
],
- services: [ 'chromedriver', 'geckodriver' ],
+ services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite,
"@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0",
"@ngx-loading-bar/router": "^6.0.0",
- "@peertube/p2p-media-loader-core": "^1.0.13",
- "@peertube/p2p-media-loader-hlsjs": "^1.0.13",
+ "@peertube/maildev": "^1.2.0",
+ "@peertube/p2p-media-loader-core": "^1.0.14",
+ "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
"@peertube/videojs-contextmenu": "^5.5.0",
"@peertube/xliffmerge": "^2.0.3",
"@popperjs/core": "^2.11.5",
"@wdio/cli": "^7.25.2",
"@wdio/local-runner": "^7.25.2",
"@wdio/mocha-framework": "^7.25.2",
+ "@wdio/shared-store-service": "^7.25.2",
"@wdio/spec-reporter": "^7.25.1",
"angular2-hotkeys": "^13.1.0",
"angularx-qrcode": "14.0.0",
<div class="anchor" id="administrators-and-sustainability"></div>
<a
- *ngIf="html.administrator || html.maintenanceLifetime || html.businessModel"
+ *ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
class="anchor-link"
routerLink="/about/instance"
fragment="administrators-and-sustainability"
</h2>
</a>
- <div class="block administrator" *ngIf="html.administrator">
+ <div class="block administrator" *ngIf="aboutHTML.administrator">
<div class="anchor" id="administrators"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">Who we are</h3>
</a>
- <div [innerHTML]="html.administrator"></div>
+ <div [innerHTML]="aboutHTML.administrator"></div>
</div>
- <div class="block creation-reason" *ngIf="html.creationReason">
+ <div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<div class="anchor" id="creation-reason"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">Why we created this instance</h3>
</a>
- <div [innerHTML]="html.creationReason"></div>
+ <div [innerHTML]="aboutHTML.creationReason"></div>
</div>
- <div class="block maintenance-lifetime" *ngIf="html.maintenanceLifetime">
+ <div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<div class="anchor" id="maintenance-lifetime"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">How long we plan to maintain this instance</h3>
</a>
- <div [innerHTML]="html.maintenanceLifetime"></div>
+ <div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
- <div class="block business-model" *ngIf="html.businessModel">
+ <div class="block business-model" *ngIf="aboutHTML.businessModel">
<div class="anchor" id="business-model"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
</a>
- <div [innerHTML]="html.businessModel"></div>
+ <div [innerHTML]="aboutHTML.businessModel"></div>
</div>
<div class="anchor" id="information"></div>
<a
- *ngIf="descriptionContent"
+ *ngIf="descriptionElement"
class="anchor-link"
routerLink="/about/instance"
fragment="information"
<h3 i18n class="section-title">Description</h3>
</a>
- <my-custom-markup-container [content]="descriptionContent"></my-custom-markup-container>
+ <my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
</div>
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="anchor" id="moderation"></div>
<a
- *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"
+ *ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
class="anchor-link"
routerLink="/about/instance"
fragment="moderation"
</h2>
</a>
- <div class="block moderation-information" *ngIf="html.moderationInformation">
+ <div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
<div class="anchor" id="moderation-information"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">Moderation information</h3>
</a>
- <div [innerHTML]="html.moderationInformation"></div>
+ <div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
- <div class="block code-of-conduct" *ngIf="html.codeOfConduct">
+ <div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
<div class="anchor" id="code-of-conduct"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">Code of conduct</h3>
</a>
- <div [innerHTML]="html.codeOfConduct"></div>
+ <div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
<div class="block terms">
<h3 i18n class="section-title">Terms</h3>
</a>
- <div [innerHTML]="html.terms"></div>
+ <div [innerHTML]="aboutHTML.terms"></div>
</div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<div class="anchor" id="other-information"></div>
<a
- *ngIf="html.hardwareInformation"
+ *ngIf="aboutHTML.hardwareInformation"
class="anchor-link"
routerLink="/about/instance"
fragment="other-information"
</h2>
</a>
- <div class="block hardware-information" *ngIf="html.hardwareInformation">
+ <div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
<div class="anchor" id="hardware-information"></div>
<a
class="anchor-link"
<h3 i18n class="section-title">Hardware information</h3>
</a>
- <div [innerHTML]="html.hardwareInformation"></div>
+ <div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
</div>
</div>
import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
-import { InstanceService } from '@app/shared/shared-instance'
+import { AboutHTML } from '@app/shared/shared-instance'
import { copyToClipboard } from '@root-helpers/utils'
import { HTMLServerConfig } from '@shared/models/server'
import { ResolverData } from './about-instance.resolver'
@ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
@ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
- shortDescription = ''
- descriptionContent: string
-
- html = {
- terms: '',
- codeOfConduct: '',
- moderationInformation: '',
- administrator: '',
- creationReason: '',
- maintenanceLifetime: '',
- businessModel: '',
- hardwareInformation: ''
- }
+ aboutHTML: AboutHTML
+ descriptionElement: HTMLDivElement
languages: string[] = []
categories: string[] = []
+ shortDescription = ''
initialized = false
private viewportScroller: ViewportScroller,
private route: ActivatedRoute,
private notifier: Notifier,
- private serverService: ServerService,
- private instanceService: InstanceService
+ private serverService: ServerService
) {}
get instanceName () {
return this.serverConfig.instance.isNSFW
}
- async ngOnInit () {
- const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData
+ ngOnInit () {
+ const { about, languages, categories, aboutHTML, descriptionElement }: ResolverData = this.route.snapshot.data.instanceData
+
+ this.aboutHTML = aboutHTML
+ this.descriptionElement = descriptionElement
+
+ this.languages = languages
+ this.categories = categories
+
+ this.shortDescription = about.instance.shortDescription
this.serverConfig = this.serverService.getHTMLConfig()
this.contactAdminModal.show(prefill)
})
- this.languages = languages
- this.categories = categories
-
- this.shortDescription = about.instance.shortDescription
- this.descriptionContent = about.instance.description
-
- this.html = await this.instanceService.buildHtml(about)
-
this.initialized = true
}
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { Resolve } from '@angular/router'
-import { InstanceService } from '@app/shared/shared-instance'
+import { CustomMarkupService } from '@app/shared/shared-custom-markup'
+import { AboutHTML, InstanceService } from '@app/shared/shared-instance'
import { About } from '@shared/models/server'
-export type ResolverData = { about: About, languages: string[], categories: string[] }
+export type ResolverData = {
+ about: About
+ languages: string[]
+ categories: string[]
+ aboutHTML: AboutHTML
+ descriptionElement: HTMLDivElement
+}
@Injectable()
export class AboutInstanceResolver implements Resolve<any> {
constructor (
- private instanceService: InstanceService
+ private instanceService: InstanceService,
+ private customMarkupService: CustomMarkupService
+
) {}
resolve () {
.pipe(
switchMap(about => {
return forkJoin([
+ Promise.resolve(about),
this.instanceService.buildTranslatedLanguages(about),
- this.instanceService.buildTranslatedCategories(about)
- ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }) as ResolverData))
+ this.instanceService.buildTranslatedCategories(about),
+ this.instanceService.buildHtml(about),
+ this.customMarkupService.buildElement(about.instance.description)
+ ])
+ }),
+ map(([ about, languages, categories, aboutHTML, { rootElement } ]) => {
+ return { about, languages, categories, aboutHTML, descriptionElement: rootElement } as ResolverData
})
)
}
children: []
}
+ if (this.hasRegistrationsRight()) {
+ moderationItems.children.push({
+ label: $localize`Registrations`,
+ routerLink: '/admin/moderation/registrations/list',
+ iconName: 'user'
+ })
+ }
+
if (this.hasAbusesRight()) {
moderationItems.children.push({
label: $localize`Reports`,
private hasVideosRight () {
return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
}
+
+ private hasRegistrationsRight () {
+ return this.auth.getUser().hasRight(UserRight.MANAGE_REGISTRATIONS)
+ }
}
import { FollowingListComponent } from './follows/following-list/following-list.component'
import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
-import { AbuseListComponent, VideoBlockListComponent } from './moderation'
+import {
+ AbuseListComponent,
+ AdminRegistrationService,
+ ProcessRegistrationModalComponent,
+ RegistrationListComponent,
+ VideoBlockListComponent
+} from './moderation'
import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
import {
UserCreateComponent,
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
EditInstanceInformationComponent,
- EditHomepageComponent
+ EditHomepageComponent,
+
+ RegistrationListComponent,
+ ProcessRegistrationModalComponent
],
exports: [
ConfigService,
PluginApiService,
EditConfigurationService,
- VideoAdminService
+ VideoAdminService,
+ AdminRegistrationService
]
})
export class AdminModule { }
<div class="peertube-select-container">
<select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
+ <option i18n value="publishedAt">Recently added videos</option>
+ <option i18n value="originallyPublishedAt">Original publication date</option>
+ <option i18n value="name">Name</option>
<option i18n value="hot">Hot videos</option>
- <option i18n value="most-viewed">Most viewed videos</option>
+ <option i18n value="most-viewed">Recent views</option>
<option i18n value="most-liked">Most liked videos</option>
+ <option i18n value="views">Global views</option>
</select>
</div>
</ng-container>
<ng-container ngProjectAs="extra">
- <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
- inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
- i18n-labelText labelText="Signup requires email verification"
- ></my-peertube-checkbox>
+ <div class="form-group">
+ <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
+ inputName="signupRequiresApproval" formControlName="requiresApproval"
+ i18n-labelText labelText="Signup requires approval by moderators"
+ ></my-peertube-checkbox>
+ </div>
- <div [ngClass]="getDisabledSignupClass()" class="mt-3">
+ <div class="form-group">
+ <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
+ inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
+ i18n-labelText labelText="Signup requires email verification"
+ ></my-peertube-checkbox>
+ </div>
+
+ <div [ngClass]="getDisabledSignupClass()">
<label i18n for="signupLimit">Signup limit</label>
<div class="number-with-unit">
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
+ requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
<my-markdown-textarea
name="instanceCustomHomepageContent" formControlName="content"
- [customMarkdownRenderer]="getCustomMarkdownRenderer()"
+ [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instanceCustomHomepage.content']"
></my-markdown-textarea>
<my-markdown-textarea
name="instanceDescription" formControlName="description"
- [customMarkdownRenderer]="getCustomMarkdownRenderer()"
+ [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instance.description']"
></my-markdown-textarea>
</div>
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers"
- [(selection)]="selectedFollows"
+ [(selection)]="selectedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
- [actions]="bulkFollowsActions" [entry]="selectedFollows"
+ [actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
</div>
templateUrl: './followers-list.component.html',
styleUrls: [ './followers-list.component.scss' ]
})
-export class FollowersListComponent extends RestTable implements OnInit {
+export class FollowersListComponent extends RestTable <ActorFollow> implements OnInit {
followers: ActorFollow[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
searchFilters: AdvancedInputFilter[] = []
- selectedFollows: ActorFollow[] = []
- bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
+ bulkActions: DropdownAction<ActorFollow[]>[] = []
constructor (
private confirmService: ConfirmService,
this.searchFilters = this.followService.buildFollowsListFilters()
- this.bulkFollowsActions = [
+ this.bulkActions = [
{
label: $localize`Reject`,
handler: follows => this.rejectFollower(follows),
}
async deleteFollowers (follows: ActorFollow[]) {
+ const icuParams = { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
+
let message = $localize`Deleted followers will be able to send again a follow request.`
message += '<br /><br />'
// eslint-disable-next-line max-len
message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)(
- { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
+ icuParams,
$localize`Do you really want to delete these follow requests?`
)
next: () => {
// eslint-disable-next-line max-len
const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
- { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
+ icuParams,
$localize`Follow requests removed`
)
return follow.follower.name + '@' + follow.follower.host
}
- isInSelectionMode () {
- return this.selectedFollows.length !== 0
- }
-
- protected reloadData () {
+ protected reloadDataInternal () {
this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search })
.subscribe({
next: resultList => {
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts"
- [(selection)]="selectedFollows"
+ [(selection)]="selectedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
- [actions]="bulkFollowsActions" [entry]="selectedFollows"
+ [actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
templateUrl: './following-list.component.html',
styleUrls: [ './following-list.component.scss' ]
})
-export class FollowingListComponent extends RestTable implements OnInit {
+export class FollowingListComponent extends RestTable <ActorFollow> implements OnInit {
@ViewChild('followModal') followModal: FollowModalComponent
following: ActorFollow[] = []
searchFilters: AdvancedInputFilter[] = []
- selectedFollows: ActorFollow[] = []
- bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
+ bulkActions: DropdownAction<ActorFollow[]>[] = []
constructor (
private notifier: Notifier,
this.searchFilters = this.followService.buildFollowsListFilters()
- this.bulkFollowsActions = [
+ this.bulkActions = [
{
label: $localize`Delete`,
handler: follows => this.removeFollowing(follows)
return follow.following.name === 'peertube'
}
- isInSelectionMode () {
- return this.selectedFollows.length !== 0
- }
-
buildFollowingName (follow: ActorFollow) {
return follow.following.name + '@' + follow.following.host
}
async removeFollowing (follows: ActorFollow[]) {
+ const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
+
const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)(
- { count: follows.length, entryName: this.buildFollowingName(follows[0]) },
+ icuParams,
$localize`Do you really want to unfollow these entries?`
)
next: () => {
// eslint-disable-next-line max-len
const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)(
- { count: follows.length, entryName: this.buildFollowingName(follows[0]) },
+ icuParams,
$localize`You are not following them anymore.`
)
})
}
- protected reloadData () {
+ protected reloadDataInternal () {
this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search })
.subscribe({
next: resultList => {
}
- protected reloadData () {
+ protected reloadDataInternal () {
const options = {
pagination: this.pagination,
sort: this.sort,
export * from './abuse-list'
export * from './instance-blocklist'
export * from './video-block-list'
+export * from './registration-list'
export * from './moderation.routes'
import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
import { UserRightGuard } from '@app/core'
import { UserRight } from '@shared/models'
+import { RegistrationListComponent } from './registration-list'
export const ModerationRoutes: Routes = [
{
}
},
- // We move this component in admin overview pages
+ {
+ path: 'registrations/list',
+ component: RegistrationListComponent,
+ canActivate: [ UserRightGuard ],
+ data: {
+ userRight: UserRight.MANAGE_REGISTRATIONS,
+ meta: {
+ title: $localize`User registrations`
+ }
+ }
+ },
+
+ // We moved this component in admin overview pages
{
path: 'video-comments',
redirectTo: 'video-comments/list',
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { from } from 'rxjs'
+import { catchError, concatMap, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { arrayify } from '@shared/core-utils'
+import { ResultList, UserRegistration, UserRegistrationUpdateState } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+@Injectable()
+export class AdminRegistrationService {
+ private static BASE_REGISTRATION_URL = environment.apiUrl + '/api/v1/users/registrations'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService
+ ) { }
+
+ listRegistrations (options: {
+ pagination: RestPagination
+ sort: SortMeta
+ search?: string
+ }) {
+ const { pagination, sort, search } = options
+
+ const url = AdminRegistrationService.BASE_REGISTRATION_URL
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) {
+ params = params.append('search', search)
+ }
+
+ return this.authHttp.get<ResultList<UserRegistration>>(url, { params })
+ .pipe(
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ acceptRegistration (options: {
+ registration: UserRegistration
+ moderationResponse: string
+ preventEmailDelivery: boolean
+ }) {
+ const { registration, moderationResponse, preventEmailDelivery } = options
+
+ const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/accept'
+ const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
+
+ return this.authHttp.post(url, body)
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ rejectRegistration (options: {
+ registration: UserRegistration
+ moderationResponse: string
+ preventEmailDelivery: boolean
+ }) {
+ const { registration, moderationResponse, preventEmailDelivery } = options
+
+ const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/reject'
+ const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
+
+ return this.authHttp.post(url, body)
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ removeRegistrations (registrationsArg: UserRegistration | UserRegistration[]) {
+ const registrations = arrayify(registrationsArg)
+
+ return from(registrations)
+ .pipe(
+ concatMap(r => this.authHttp.delete(AdminRegistrationService.BASE_REGISTRATION_URL + '/' + r.id)),
+ toArray(),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+}
--- /dev/null
+export * from './admin-registration.service'
+export * from './process-registration-modal.component'
+export * from './process-registration-validators'
+export * from './registration-list.component'
--- /dev/null
+<ng-template #modal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">
+ <ng-container *ngIf="isAccept()">Accept {{ registration.username }} registration</ng-container>
+ <ng-container *ngIf="isReject()">Reject {{ registration.username }} registration</ng-container>
+ </h4>
+
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <form novalidate [formGroup]="form" (ngSubmit)="processRegistration()">
+ <div class="modal-body mb-3">
+
+ <div i18n *ngIf="!registration.emailVerified" class="alert alert-warning">
+ Registration email has not been verified. Email delivery has been disabled by default.
+ </div>
+
+ <div class="description">
+ <ng-container *ngIf="isAccept()">
+ <p i18n>
+ <strong>Accepting</strong> <em>{{ registration.username }}</em> registration will create the account and channel.
+ </p>
+
+ <p *ngIf="isEmailEnabled()" i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
+ An email will be sent to <em>{{ registration.email }}</em> explaining its account has been created with the moderation response you'll write below.
+ </p>
+
+ <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
+ Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its account has been created.
+ </div>
+ </ng-container>
+
+ <ng-container *ngIf="isReject()">
+ <p i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
+ An email will be sent to <em>{{ registration.email }}</em> explaining its registration request has been <strong>rejected</strong> with the moderation response you'll write below.
+ </p>
+
+ <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
+ Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its registration request has been rejected.
+ </div>
+ </ng-container>
+ </div>
+
+ <div class="form-group">
+ <label for="moderationResponse" i18n>Send a message to the user</label>
+
+ <textarea
+ formControlName="moderationResponse" ngbAutofocus name="moderationResponse" id="moderationResponse"
+ [ngClass]="{ 'input-error': formErrors['moderationResponse'] }" class="form-control"
+ ></textarea>
+
+ <div *ngIf="formErrors.moderationResponse" class="form-error">
+ {{ formErrors.moderationResponse }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="preventEmailDelivery" formControlName="preventEmailDelivery" [disabled]="!isEmailEnabled()"
+ i18n-labelText labelText="Prevent email from being sent to the user"
+ ></my-peertube-checkbox>
+ </div>
+ </div>
+
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
+ (click)="hide()" (key.enter)="hide()"
+ >
+
+ <input type="submit" [value]="getSubmitValue()" class="peertube-button orange-button" [disabled]="!form.valid">
+ </div>
+ </form>
+</ng-template>
--- /dev/null
+@use '_variables' as *;
+@use '_mixins' as *;
+
--- /dev/null
+import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { Notifier, ServerService } from '@app/core'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { UserRegistration } from '@shared/models'
+import { AdminRegistrationService } from './admin-registration.service'
+import { REGISTRATION_MODERATION_RESPONSE_VALIDATOR } from './process-registration-validators'
+
+@Component({
+ selector: 'my-process-registration-modal',
+ templateUrl: './process-registration-modal.component.html',
+ styleUrls: [ './process-registration-modal.component.scss' ]
+})
+export class ProcessRegistrationModalComponent extends FormReactive implements OnInit {
+ @ViewChild('modal', { static: true }) modal: NgbModal
+
+ @Output() registrationProcessed = new EventEmitter()
+
+ registration: UserRegistration
+
+ private openedModal: NgbModalRef
+ private processMode: 'accept' | 'reject'
+
+ constructor (
+ protected formReactiveService: FormReactiveService,
+ private server: ServerService,
+ private modalService: NgbModal,
+ private notifier: Notifier,
+ private registrationService: AdminRegistrationService
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ moderationResponse: REGISTRATION_MODERATION_RESPONSE_VALIDATOR,
+ preventEmailDelivery: null
+ })
+ }
+
+ isAccept () {
+ return this.processMode === 'accept'
+ }
+
+ isReject () {
+ return this.processMode === 'reject'
+ }
+
+ openModal (registration: UserRegistration, mode: 'accept' | 'reject') {
+ this.processMode = mode
+ this.registration = registration
+
+ this.form.patchValue({
+ preventEmailDelivery: !this.isEmailEnabled() || registration.emailVerified !== true
+ })
+
+ this.openedModal = this.modalService.open(this.modal, { centered: true })
+ }
+
+ hide () {
+ this.form.reset()
+
+ this.openedModal.close()
+ }
+
+ getSubmitValue () {
+ if (this.isAccept()) {
+ return $localize`Accept registration`
+ }
+
+ return $localize`Reject registration`
+ }
+
+ processRegistration () {
+ if (this.isAccept()) return this.acceptRegistration()
+
+ return this.rejectRegistration()
+ }
+
+ isEmailEnabled () {
+ return this.server.getHTMLConfig().email.enabled
+ }
+
+ isPreventEmailDeliveryChecked () {
+ return this.form.value.preventEmailDelivery
+ }
+
+ private acceptRegistration () {
+ this.registrationService.acceptRegistration({
+ registration: this.registration,
+ moderationResponse: this.form.value.moderationResponse,
+ preventEmailDelivery: this.form.value.preventEmailDelivery
+ }).subscribe({
+ next: () => {
+ this.notifier.success($localize`${this.registration.username} account created`)
+
+ this.registrationProcessed.emit()
+ this.hide()
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ private rejectRegistration () {
+ this.registrationService.rejectRegistration({
+ registration: this.registration,
+ moderationResponse: this.form.value.moderationResponse,
+ preventEmailDelivery: this.form.value.preventEmailDelivery
+ }).subscribe({
+ next: () => {
+ this.notifier.success($localize`${this.registration.username} registration rejected`)
+
+ this.registrationProcessed.emit()
+ this.hide()
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+}
--- /dev/null
+import { Validators } from '@angular/forms'
+import { BuildFormValidator } from '@app/shared/form-validators'
+
+export const REGISTRATION_MODERATION_RESPONSE_VALIDATOR: BuildFormValidator = {
+ VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+ MESSAGES: {
+ required: $localize`Moderation response is required.`,
+ minlength: $localize`Moderation response must be at least 2 characters long.`,
+ maxlength: $localize`Moderation response cannot be more than 3000 characters long.`
+ }
+}
--- /dev/null
+<h1>
+ <my-global-icon iconName="user" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Registration requests</ng-container>
+</h1>
+
+<p-table
+ [value]="registrations" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
+ [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
+ [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
+ [(selection)]="selectedRows" [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+ currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} registrations"
+ [expandedRowKeys]="expandedRows"
+>
+ <ng-template pTemplate="caption">
+ <div class="caption">
+ <div class="left-buttons">
+ <my-action-dropdown
+ *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
+ [actions]="bulkActions" [entry]="selectedRows"
+ >
+ </my-action-dropdown>
+ </div>
+
+ <div class="ms-auto">
+ <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
+ </div>
+ </div>
+ </ng-template>
+
+ <ng-template pTemplate="header">
+ <tr> <!-- header -->
+ <th style="width: 40px">
+ <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
+ </th>
+ <th style="width: 40px;"></th>
+ <th style="width: 150px;"></th>
+ <th i18n>Account</th>
+ <th i18n>Email</th>
+ <th i18n>Channel</th>
+ <th i18n>Registration reason</th>
+ <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
+ <th i18n>Moderation response</th>
+ <th style="width: 150px;" i18n pSortableColumn="createdAt">Requested on <p-sortIcon field="createdAt"></p-sortIcon></th>
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="body" let-expanded="expanded" let-registration>
+ <tr [pSelectableRow]="registration">
+ <td class="checkbox-cell">
+ <p-tableCheckbox [value]="registration" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
+ </td>
+
+ <td class="expand-cell" [pRowToggler]="registration">
+ <my-table-expander-icon [expanded]="expanded"></my-table-expander-icon>
+ </td>
+
+ <td class="action-cell">
+ <my-action-dropdown
+ [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
+ i18n-label label="Actions" [actions]="registrationActions" [entry]="registration"
+ ></my-action-dropdown>
+ </td>
+
+ <td>
+ <div class="chip two-lines">
+ <div>
+ <span>{{ registration.username }}</span>
+ <span class="muted">{{ registration.accountDisplayName }}</span>
+ </div>
+ </div>
+ </td>
+
+ <td>
+ <my-user-email-info [entry]="registration" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
+ </td>
+
+ <td>
+ <div class="chip two-lines">
+ <div>
+ <span>{{ registration.channelHandle }}</span>
+ <span class="muted">{{ registration.channelDisplayName }}</span>
+ </div>
+ </div>
+ </td>
+
+ <td container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
+ {{ registration.registrationReason }}
+ </td>
+
+ <td class="c-hand abuse-states" [pRowToggler]="registration">
+ <my-global-icon *ngIf="isRegistrationAccepted(registration)" [title]="registration.state.label" iconName="tick"></my-global-icon>
+ <my-global-icon *ngIf="isRegistrationRejected(registration)" [title]="registration.state.label" iconName="cross"></my-global-icon>
+ </td>
+
+ <td container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
+ {{ registration.moderationResponse }}
+ </td>
+
+ <td class="c-hand" [pRowToggler]="registration">{{ registration.createdAt | date: 'short' }}</td>
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="rowexpansion" let-registration>
+ <tr>
+ <td colspan="9">
+ <div class="moderation-expanded">
+ <div class="left">
+ <div class="d-flex">
+ <span class="moderation-expanded-label" i18n>Registration reason:</span>
+ <span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
+ </div>
+
+ <div *ngIf="registration.moderationResponse">
+ <span class="moderation-expanded-label" i18n>Moderation response:</span>
+ <span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
+ </div>
+ </div>
+ </div>
+ </td>
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="emptymessage">
+ <tr>
+ <td colspan="9">
+ <div class="no-results">
+ <ng-container *ngIf="search" i18n>No registrations found matching current filters.</ng-container>
+ <ng-container *ngIf="!search" i18n>No registrations found.</ng-container>
+ </div>
+ </td>
+ </tr>
+ </ng-template>
+</p-table>
+
+<my-process-registration-modal #processRegistrationModal (registrationProcessed)="onRegistrationProcessed()"></my-process-registration-modal>
--- /dev/null
+@use '_mixins' as *;
+@use '_variables' as *;
+
+my-global-icon {
+ width: 24px;
+ height: 24px;
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
+import { prepareIcu } from '@app/helpers'
+import { AdvancedInputFilter } from '@app/shared/shared-forms'
+import { DropdownAction } from '@app/shared/shared-main'
+import { UserRegistration, UserRegistrationState } from '@shared/models'
+import { AdminRegistrationService } from './admin-registration.service'
+import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
+
+@Component({
+ selector: 'my-registration-list',
+ templateUrl: './registration-list.component.html',
+ styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ]
+})
+export class RegistrationListComponent extends RestTable <UserRegistration> implements OnInit {
+ @ViewChild('processRegistrationModal', { static: true }) processRegistrationModal: ProcessRegistrationModalComponent
+
+ registrations: (UserRegistration & { registrationReasonHTML?: string, moderationResponseHTML?: string })[] = []
+ totalRecords = 0
+ sort: SortMeta = { field: 'createdAt', order: -1 }
+ pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+ registrationActions: DropdownAction<UserRegistration>[][] = []
+ bulkActions: DropdownAction<UserRegistration[]>[] = []
+
+ inputFilters: AdvancedInputFilter[] = []
+
+ requiresEmailVerification: boolean
+
+ constructor (
+ protected route: ActivatedRoute,
+ protected router: Router,
+ private server: ServerService,
+ private notifier: Notifier,
+ private markdownRenderer: MarkdownService,
+ private confirmService: ConfirmService,
+ private adminRegistrationService: AdminRegistrationService
+ ) {
+ super()
+
+ this.registrationActions = [
+ [
+ {
+ label: $localize`Accept this request`,
+ handler: registration => this.openRegistrationRequestProcessModal(registration, 'accept'),
+ isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
+ },
+ {
+ label: $localize`Reject this request`,
+ handler: registration => this.openRegistrationRequestProcessModal(registration, 'reject'),
+ isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
+ },
+ {
+ label: $localize`Remove this request`,
+ handler: registration => this.removeRegistrations([ registration ])
+ }
+ ]
+ ]
+
+ this.bulkActions = [
+ {
+ label: $localize`Delete`,
+ handler: registrations => this.removeRegistrations(registrations)
+ }
+ ]
+ }
+
+ ngOnInit () {
+ this.initialize()
+
+ this.server.getConfig()
+ .subscribe(config => {
+ this.requiresEmailVerification = config.signup.requiresEmailVerification
+ })
+ }
+
+ getIdentifier () {
+ return 'RegistrationListComponent'
+ }
+
+ isRegistrationAccepted (registration: UserRegistration) {
+ return registration.state.id === UserRegistrationState.ACCEPTED
+ }
+
+ isRegistrationRejected (registration: UserRegistration) {
+ return registration.state.id === UserRegistrationState.REJECTED
+ }
+
+ onRegistrationProcessed () {
+ this.reloadData()
+ }
+
+ protected reloadDataInternal () {
+ this.adminRegistrationService.listRegistrations({
+ pagination: this.pagination,
+ sort: this.sort,
+ search: this.search
+ }).subscribe({
+ next: async resultList => {
+ this.totalRecords = resultList.total
+ this.registrations = resultList.data
+
+ for (const registration of this.registrations) {
+ registration.registrationReasonHTML = await this.toHtml(registration.registrationReason)
+ registration.moderationResponseHTML = await this.toHtml(registration.moderationResponse)
+ }
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ private openRegistrationRequestProcessModal (registration: UserRegistration, mode: 'accept' | 'reject') {
+ this.processRegistrationModal.openModal(registration, mode)
+ }
+
+ private async removeRegistrations (registrations: UserRegistration[]) {
+ const icuParams = { count: registrations.length, username: registrations[0].username }
+
+ // eslint-disable-next-line max-len
+ const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)(
+ icuParams,
+ $localize`Do you really want to delete these registration requests?`
+ )
+
+ const res = await this.confirmService.confirm(message, $localize`Delete`)
+ if (res === false) return
+
+ this.adminRegistrationService.removeRegistrations(registrations)
+ .subscribe({
+ next: () => {
+ // eslint-disable-next-line max-len
+ const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)(
+ icuParams,
+ $localize`Registration requests removed`
+ )
+
+ this.notifier.success(message)
+ this.reloadData()
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ private toHtml (text: string) {
+ return this.markdownRenderer.textMarkdownToHTML({ markdown: text })
+ }
+}
})
}
- protected reloadData () {
+ protected reloadDataInternal () {
this.videoBlocklistService.listBlocks({
pagination: this.pagination,
sort: this.sort,
search: this.search
- })
- .subscribe({
- next: async resultList => {
- this.totalRecords = resultList.total
+ }).subscribe({
+ next: async resultList => {
+ this.totalRecords = resultList.total
- this.blocklist = resultList.data
+ this.blocklist = resultList.data
- for (const element of this.blocklist) {
- Object.assign(element, {
- reasonHtml: await this.toHtml(element.reason)
- })
- }
- },
+ for (const element of this.blocklist) {
+ Object.assign(element, {
+ reasonHtml: await this.toHtml(element.reason)
+ })
+ }
+ },
- error: err => this.notifier.error(err.message)
- })
+ error: err => this.notifier.error(err.message)
+ })
}
}
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} comments"
- [expandedRowKeys]="expandedRows" [(selection)]="selectedComments"
+ [expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
>
<ng-template pTemplate="caption">
<div class="caption">
<div>
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
- [actions]="bulkCommentActions" [entry]="selectedComments"
+ [actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
</div>
templateUrl: './video-comment-list.component.html',
styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ]
})
-export class VideoCommentListComponent extends RestTable implements OnInit {
+export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit {
comments: VideoCommentAdmin[]
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
}
]
- selectedComments: VideoCommentAdmin[] = []
- bulkCommentActions: DropdownAction<VideoCommentAdmin[]>[] = []
+ bulkActions: DropdownAction<VideoCommentAdmin[]>[] = []
inputFilters: AdvancedInputFilter[] = [
{
ngOnInit () {
this.initialize()
- this.bulkCommentActions = [
+ this.bulkActions = [
{
label: $localize`Delete`,
handler: comments => this.removeComments(comments),
return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
}
- isInSelectionMode () {
- return this.selectedComments.length !== 0
- }
-
- reloadData () {
+ protected reloadDataInternal () {
this.videoCommentService.getAdminVideoComments({
pagination: this.pagination,
sort: this.sort,
error: err => this.notifier.error(err.message),
- complete: () => this.selectedComments = []
+ complete: () => this.selectedRows = []
})
}
<p-table
[value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
- [(selection)]="selectedUsers" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
+ [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
[expandedRowKeys]="expandedRows"
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
- [actions]="bulkUserActions" [entry]="selectedUsers"
+ [actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
<div class="chip two-lines">
<my-actor-avatar [actor]="user?.account" actorType="account" size="32"></my-actor-avatar>
<div>
- <span class="user-table-primary-text">{{ user.account.displayName }}</span>
+ <span>{{ user.account.displayName }}</span>
<span class="muted">{{ user.username }}</span>
</div>
</div>
<span *ngIf="!user.blocked" class="pt-badge" [ngClass]="getRoleClass(user.role.id)">{{ user.role.label }}</span>
</td>
- <td *ngIf="isSelected('email')" [title]="user.email">
- <ng-container *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus">
- <a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
- </ng-container>
+ <td *ngIf="isSelected('email')">
+ <my-user-email-info [entry]="user" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
</td>
- <ng-template #emailWithVerificationStatus>
- <td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login">
- <em>? {{ user.email }}</em>
- </td>
- <ng-template #emailVerifiedNotFalse>
- <td i18n-title title="User's email is verified / User can login without email verification">
- ✓ {{ user.email }}
- </td>
- </ng-template>
- </ng-template>
-
<td *ngIf="isSelected('quota')">
<div class="progress" i18n-title title="Total video quota">
<div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }"
background-color: lighten($color: $red, $amount: 40) !important;
}
-.table-email {
- @include disable-default-a-behaviour;
-
- color: pvar(--mainForegroundColor);
-}
-
.banned-info {
font-style: italic;
}
width: 18px;
}
-.chip {
- @include chip;
-}
-
.progress {
@include progressbar($small: true);
templateUrl: './user-list.component.html',
styleUrls: [ './user-list.component.scss' ]
})
-export class UserListComponent extends RestTable implements OnInit {
+export class UserListComponent extends RestTable <User> implements OnInit {
private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
@ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
highlightBannedUsers = false
- selectedUsers: User[] = []
- bulkUserActions: DropdownAction<User[]>[][] = []
+ bulkActions: DropdownAction<User[]>[][] = []
columns: { id: string, label: string }[]
inputFilters: AdvancedInputFilter[] = [
this.initialize()
- this.bulkUserActions = [
+ this.bulkActions = [
[
{
label: $localize`Delete`,
const res = await this.confirmService.confirm(message, $localize`Delete`)
if (res === false) return
- this.userAdminService.removeUser(users)
+ this.userAdminService.removeUsers(users)
.subscribe({
next: () => {
this.notifier.success(
})
}
- isInSelectionMode () {
- return this.selectedUsers.length !== 0
- }
-
- protected reloadData () {
- this.selectedUsers = []
-
+ protected reloadDataInternal () {
this.userAdminService.getUsers({
pagination: this.pagination,
sort: this.sort,
<p-table
[value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
- [(selection)]="selectedVideos" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
+ [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} videos"
[expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }"
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
- [actions]="bulkVideoActions" [entry]="selectedVideos"
+ [actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
</div>
templateUrl: './video-list.component.html',
styleUrls: [ './video-list.component.scss' ]
})
-export class VideoListComponent extends RestTable implements OnInit {
+export class VideoListComponent extends RestTable <Video> implements OnInit {
@ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
videos: Video[] = []
sort: SortMeta = { field: 'publishedAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
- bulkVideoActions: DropdownAction<Video[]>[][] = []
-
- selectedVideos: Video[] = []
+ bulkActions: DropdownAction<Video[]>[][] = []
inputFilters: AdvancedInputFilter[]
this.inputFilters = this.videoAdminService.buildAdminInputFilter()
- this.bulkVideoActions = [
+ this.bulkActions = [
[
{
label: $localize`Delete`,
return 'VideoListComponent'
}
- isInSelectionMode () {
- return this.selectedVideos.length !== 0
- }
-
getPrivacyBadgeClass (video: Video) {
if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green'
return files.reduce((p, f) => p += f.size, 0)
}
- reloadData () {
- this.selectedVideos = []
+ async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
+ const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
+ const res = await this.confirmService.confirm(message, $localize`Delete file`)
+ if (res === false) return
+
+ this.videoService.removeFile(video.uuid, file.id, type)
+ .subscribe({
+ next: () => {
+ this.notifier.success($localize`File removed.`)
+ this.reloadData()
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+ protected reloadDataInternal () {
this.loading = true
this.videoAdminService.getAdminVideos({
})
}
- async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
- const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
- const res = await this.confirmService.confirm(message, $localize`Delete file`)
- if (res === false) return
-
- this.videoService.removeFile(video.uuid, file.id, type)
- .subscribe({
- next: () => {
- this.notifier.success($localize`File removed.`)
- this.reloadData()
- },
-
- error: err => this.notifier.error(err.message)
- })
- }
-
private async removeVideos (videos: Video[]) {
const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
{ count: videos.length },
import { NgModule } from '@angular/core'
import { SharedMainModule } from '../../shared/shared-main/shared-main.module'
+import { UserEmailInfoComponent } from './user-email-info.component'
import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
@NgModule({
],
declarations: [
- UserRealQuotaInfoComponent
+ UserRealQuotaInfoComponent,
+ UserEmailInfoComponent
],
exports: [
- UserRealQuotaInfoComponent
+ UserRealQuotaInfoComponent,
+ UserEmailInfoComponent
],
providers: []
--- /dev/null
+<ng-container>
+ <a [href]="'mailto:' + entry.email" [title]="getTitle()">
+ <ng-container *ngIf="!requiresEmailVerification">
+ {{ entry.email }}
+ </ng-container>
+
+ <ng-container *ngIf="requiresEmailVerification">
+ <em *ngIf="!entry.emailVerified">? {{ entry.email }}</em>
+
+ <ng-container *ngIf="entry.emailVerified === true">✓ {{ entry.email }}</ng-container>
+ </ng-container>
+ </a>
+</ng-container>
--- /dev/null
+@use '_variables' as *;
+@use '_mixins' as *;
+
+a {
+ color: pvar(--mainForegroundColor);
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+import { User, UserRegistration } from '@shared/models/users'
+
+@Component({
+ selector: 'my-user-email-info',
+ templateUrl: './user-email-info.component.html',
+ styleUrls: [ './user-email-info.component.scss' ]
+})
+export class UserEmailInfoComponent {
+ @Input() entry: User | UserRegistration
+ @Input() requiresEmailVerification: boolean
+
+ getTitle () {
+ if (this.entry.emailVerified) {
+ return $localize`User email has been verified`
+ }
+
+ return $localize`User email hasn't been verified`
+ }
+}
private restExtractor: RestExtractor
) {}
- getJobs (options: {
+ listJobs (options: {
jobState?: JobStateClient
jobType: JobTypeClient
pagination: RestPagination
this.reloadData()
}
- protected reloadData () {
+ protected reloadDataInternal () {
let jobState = this.jobState as JobState
if (this.jobState === 'all') jobState = null
this.jobsService
- .getJobs({
+ .listJobs({
jobState,
jobType: this.jobType,
pagination: this.pagination,
+import { environment } from 'src/environments/environment'
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { PluginsManager } from '@root-helpers/plugins-manager'
-import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
+import { getExternalAuthHref } from '@shared/core-utils'
+import { RegisteredExternalAuthConfig, ServerConfig, ServerErrorCode } from '@shared/models'
@Component({
selector: 'my-login',
}
getAuthHref (auth: RegisteredExternalAuthConfig) {
- return PluginsManager.getExternalAuthHref(auth)
+ return getExternalAuthHref(environment.apiUrl, auth)
}
login () {
}
private handleError (err: any) {
+ console.log(err)
+
if (this.authService.isOTPMissingError(err)) {
this.otpStep = true
return
}
- if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
- else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
- else this.error = err.message
+ if (err.message.includes('credentials are invalid')) {
+ this.error = $localize`Incorrect username or password.`
+ return
+ }
+
+ if (err.message.includes('blocked')) {
+ this.error = $localize`Your account is blocked.`
+ return
+ }
+
+ if (err.body?.code === ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL) {
+ this.error = $localize`This account is awaiting approval by moderators.`
+ return
+ }
+
+ if (err.body?.code === ServerErrorCode.ACCOUNT_APPROVAL_REJECTED) {
+ this.error = $localize`Registration approval has been rejected for this account.`
+ return
+ }
+
+ this.error = err.message
}
}
@use '_miniature' as *;
@use '_mixins' as *;
-.chip {
- @include chip;
-}
-
.video-table-video {
display: inline-flex;
})
}
- protected reloadData () {
+ protected reloadDataInternal () {
return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort)
.subscribe({
next: resultList => {
]
}
- protected reloadData () {
+ protected reloadDataInternal () {
this.error = undefined
this.authService.userInformationLoaded
})
}
- protected reloadData () {
+ protected reloadDataInternal () {
this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search)
.subscribe({
next: resultList => {
</div>
<ng-container *ngIf="!signupDisabled">
- <h1 i18n class="title-page-v2">
+ <h1 class="title-page-v2">
<strong class="underline-orange">{{ instanceName }}</strong>
>
- Create an account
+ <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
</h1>
<div class="register-content">
<my-custom-stepper linear>
<cdk-step i18n-label label="About" [editable]="!signupSuccess">
- <my-signup-step-title mascotImageName="about" i18n>
- <strong>Create an account</strong>
- <div>on {{ instanceName }}</div>
+ <my-signup-step-title mascotImageName="about">
+ <strong>
+ <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
+ </strong>
+
+ <div i18n>on {{ instanceName }}</div>
</my-signup-step-title>
- <my-register-step-about [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about>
+ <my-register-step-about [requiresApproval]="requiresApproval" [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about>
<div class="step-buttons">
<a i18n class="skip-step underline-orange" routerLink="/login">
<strong>I already have an account</strong>, I log in
</a>
- <button i18n cdkStepperNext>Create an account</button>
+ <button cdkStepperNext>
+ <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
+ </button>
</div>
</cdk-step>
></my-instance-about-accordion>
<my-register-step-terms
- [hasCodeOfConduct]="!!aboutHtml.codeOfConduct"
- [minimumAge]="minimumAge"
+ [hasCodeOfConduct]="!!aboutHtml.codeOfConduct" [minimumAge]="minimumAge" [instanceName]="instanceName"
+ [requiresApproval]="requiresApproval"
(formBuilt)="onTermsFormBuilt($event)" (termsClick)="onTermsClick()" (codeOfConductClick)="onCodeOfConductClick()"
></my-register-step-terms>
<div class="skip-step-description" i18n>You will be able to create a channel later</div>
</div>
- <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()" i18n>
- Create my account
+ <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()">
+ <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
</button>
</div>
</cdk-step>
<cdk-step #lastStep i18n-label label="Done!" [editable]="false">
- <div *ngIf="!signupSuccess && !signupError" class="done-loader">
+ <!-- Account creation can be a little bit long so display a loader -->
+ <div *ngIf="!requiresApproval && !signupSuccess && !signupError" class="done-loader">
<my-loader [loading]="true"></my-loader>
<div i18n>PeerTube is creating your account...</div>
<div *ngIf="signupError" class="alert alert-danger">{{ signupError }}</div>
- <my-signup-success *ngIf="signupSuccess" [requiresEmailVerification]="requiresEmailVerification"></my-signup-success>
+ <my-signup-success-before-email
+ *ngIf="signupSuccess"
+ [requiresEmailVerification]="requiresEmailVerification" [requiresApproval]="requiresApproval" [instanceName]="instanceName"
+ ></my-signup-success-before-email>
<div *ngIf="signupError" class="steps-button">
<button cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button>
import { AuthService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
-import { UserSignupService } from '@app/shared/shared-users'
import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'
import { UserRegister } from '@shared/models'
import { ServerConfig } from '@shared/models/server'
+import { SignupService } from '../shared/signup.service'
@Component({
selector: 'my-register',
constructor (
private route: ActivatedRoute,
private authService: AuthService,
- private userSignupService: UserSignupService,
+ private signupService: SignupService,
private hooks: HooksService
) { }
return this.serverConfig.signup.requiresEmailVerification
}
+ get requiresApproval () {
+ return this.serverConfig.signup.requiresApproval
+ }
+
get minimumAge () {
return this.serverConfig.signup.minimumAge
}
skipChannelCreation () {
this.formStepChannel.reset()
this.lastStep.select()
+
this.signup()
}
async signup () {
this.signupError = undefined
- const body: UserRegister = await this.hooks.wrapObject(
+ const termsForm = this.formStepTerms.value
+ const userForm = this.formStepUser.value
+ const channelForm = this.formStepChannel?.value
+
+ const channel = this.formStepChannel?.value?.name
+ ? { name: channelForm?.name, displayName: channelForm?.displayName }
+ : undefined
+
+ const body = await this.hooks.wrapObject(
{
- ...this.formStepUser.value,
+ username: userForm.username,
+ password: userForm.password,
+ email: userForm.email,
+ displayName: userForm.displayName,
+
+ registrationReason: termsForm.registrationReason,
- channel: this.formStepChannel?.value?.name
- ? this.formStepChannel.value
- : undefined
+ channel
},
'signup',
'filter:api.signup.registration.create.params'
)
- this.userSignupService.signup(body).subscribe({
+ const obs = this.requiresApproval
+ ? this.signupService.requestSignup(body)
+ : this.signupService.directSignup(body)
+
+ obs.subscribe({
next: () => {
- if (this.requiresEmailVerification) {
+ if (this.requiresEmailVerification || this.requiresApproval) {
this.signupSuccess = true
return
}
// Auto login
- this.authService.login({ username: body.username, password: body.password })
- .subscribe({
- next: () => {
- this.signupSuccess = true
- },
-
- error: err => {
- this.signupError = err.message
- }
- })
+ this.autoLogin(body)
},
error: err => {
}
})
}
+
+ private autoLogin (body: UserRegister) {
+ this.authService.login({ username: body.username, password: body.password })
+ .subscribe({
+ next: () => {
+ this.signupSuccess = true
+ },
+
+ error: err => {
+ this.signupError = err.message
+ }
+ })
+ }
}
--- /dev/null
+export * from './register-validators'
--- /dev/null
+import { Validators } from '@angular/forms'
+import { BuildFormValidator } from '@app/shared/form-validators'
+
+export const REGISTER_TERMS_VALIDATOR: BuildFormValidator = {
+ VALIDATORS: [ Validators.requiredTrue ],
+ MESSAGES: {
+ required: $localize`You must agree with the instance terms in order to register on it.`
+ }
+}
+
+export const REGISTER_REASON_VALIDATOR: BuildFormValidator = {
+ VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+ MESSAGES: {
+ required: $localize`Registration reason is required.`,
+ minlength: $localize`Registration reason must be at least 2 characters long.`,
+ maxlength: $localize`Registration reason cannot be more than 3000 characters long.`
+ }
+}
<li i18n>Have access to your <strong>watch history</strong></li>
<li *ngIf="!videoUploadDisabled" i18n>Create your channel to <strong>publish videos</strong></li>
</ul>
+
+ <p *ngIf="requiresApproval" i18n>
+ Moderators of {{ instanceName }} will have to approve your registration request once you have finished to fill the form.
+ </p>
</div>
<div>
styleUrls: [ './register-step-about.component.scss' ]
})
export class RegisterStepAboutComponent {
+ @Input() requiresApproval: boolean
@Input() videoUploadDisabled: boolean
constructor (private serverService: ServerService) {
import { pairwise } from 'rxjs/operators'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms'
+import { SignupService } from '@app/+signup/shared/signup.service'
import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { UserSignupService } from '@app/shared/shared-users'
@Component({
selector: 'my-register-step-channel',
constructor (
protected formReactiveService: FormReactiveService,
- private userSignupService: UserSignupService
+ private signupService: SignupService
) {
super()
}
private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
const name = this.form.value['name'] || ''
- const newName = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, name)
+ const newName = this.signupService.getNewUsername(oldDisplayName, newDisplayName, name)
this.form.patchValue({ name: newName })
}
}
<form role="form" [formGroup]="form">
+
+ <div *ngIf="requiresApproval" class="form-group">
+ <label i18n for="registrationReason">Why do you want to join {{ instanceName }}?</label>
+
+ <textarea
+ id="registrationReason" formControlName="registrationReason" class="form-control" rows="4"
+ [ngClass]="{ 'input-error': formErrors['registrationReason'] }"
+ ></textarea>
+
+ <div *ngIf="formErrors.registrationReason" class="form-error">{{ formErrors.registrationReason }}</div>
+ </div>
+
<div class="form-group">
<my-peertube-checkbox inputName="terms" formControlName="terms">
<ng-template ptTemplate="label">
I am at least {{ minimumAge }} years old and agree
to the <a class="link-orange" (click)="onTermsClick($event)" href='#'>Terms</a>
<ng-container *ngIf="hasCodeOfConduct"> and to the <a class="link-orange" (click)="onCodeOfConductClick($event)" href='#'>Code of Conduct</a></ng-container>
- of this instance
+ of {{ instanceName }}
</ng-container>
</ng-template>
</my-peertube-checkbox>
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms'
-import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { REGISTER_REASON_VALIDATOR, REGISTER_TERMS_VALIDATOR } from '../shared'
@Component({
selector: 'my-register-step-terms',
})
export class RegisterStepTermsComponent extends FormReactive implements OnInit {
@Input() hasCodeOfConduct = false
+ @Input() requiresApproval: boolean
@Input() minimumAge = 16
+ @Input() instanceName: string
@Output() formBuilt = new EventEmitter<FormGroup>()
@Output() termsClick = new EventEmitter<void>()
ngOnInit () {
this.buildForm({
- terms: USER_TERMS_VALIDATOR
+ terms: REGISTER_TERMS_VALIDATOR,
+
+ registrationReason: this.requiresApproval
+ ? REGISTER_REASON_VALIDATOR
+ : null
})
setTimeout(() => this.formBuilt.emit(this.form))
import { pairwise } from 'rxjs/operators'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms'
+import { SignupService } from '@app/+signup/shared/signup.service'
import {
USER_DISPLAY_NAME_REQUIRED_VALIDATOR,
USER_EMAIL_VALIDATOR,
USER_USERNAME_VALIDATOR
} from '@app/shared/form-validators/user-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { UserSignupService } from '@app/shared/shared-users'
@Component({
selector: 'my-register-step-user',
constructor (
protected formReactiveService: FormReactiveService,
- private userSignupService: UserSignupService
+ private signupService: SignupService
) {
super()
}
private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
const username = this.form.value['username'] || ''
- const newUsername = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, username)
+ const newUsername = this.signupService.getNewUsername(oldDisplayName, newDisplayName, username)
this.form.patchValue({ username: newUsername })
}
}
import { Component, OnInit } from '@angular/core'
+import { SignupService } from '@app/+signup/shared/signup.service'
import { Notifier, RedirectService, ServerService } from '@app/core'
import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { UserSignupService } from '@app/shared/shared-users'
@Component({
selector: 'my-verify-account-ask-send-email',
constructor (
protected formReactiveService: FormReactiveService,
- private userSignupService: UserSignupService,
+ private signupService: SignupService,
private serverService: ServerService,
private notifier: Notifier,
private redirectService: RedirectService
askSendVerifyEmail () {
const email = this.form.value['verify-email-email']
- this.userSignupService.askSendVerifyEmail(email)
+ this.signupService.askSendVerifyEmail(email)
.subscribe({
next: () => {
this.notifier.success($localize`An email with verification link will be sent to ${email}.`)
-<div class="margin-content">
- <h1 i18n class="title-page">Verify account email confirmation</h1>
+<div *ngIf="loaded" class="margin-content">
+ <h1 i18n class="title-page">Verify email</h1>
- <my-signup-success i18n *ngIf="!isPendingEmail && success" [requiresEmailVerification]="false">
- </my-signup-success>
+ <my-signup-success-after-email
+ *ngIf="displaySignupSuccess()"
+ [requiresApproval]="isRegistrationRequest() && requiresApproval"
+ >
+ </my-signup-success-after-email>
- <div i18n class="alert alert-success" *ngIf="isPendingEmail && success">Email updated.</div>
+ <div i18n class="alert alert-success" *ngIf="!isRegistrationRequest() && isPendingEmail && success">Email updated.</div>
<div class="alert alert-danger" *ngIf="failed">
<span i18n>An error occurred.</span>
- <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email" [queryParams]="{ isPendingEmail: isPendingEmail }">Request new verification email</a>
+ <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email">
+ Request a new verification email
+ </a>
</div>
</div>
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
-import { AuthService, Notifier } from '@app/core'
-import { UserSignupService } from '@app/shared/shared-users'
+import { SignupService } from '@app/+signup/shared/signup.service'
+import { AuthService, Notifier, ServerService } from '@app/core'
@Component({
selector: 'my-verify-account-email',
failed = false
isPendingEmail = false
+ requiresApproval: boolean
+ loaded = false
+
private userId: number
+ private registrationId: number
private verificationString: string
constructor (
- private userSignupService: UserSignupService,
+ private signupService: SignupService,
+ private server: ServerService,
private authService: AuthService,
private notifier: Notifier,
private route: ActivatedRoute
) {
}
+ get instanceName () {
+ return this.server.getHTMLConfig().instance.name
+ }
+
ngOnInit () {
const queryParams = this.route.snapshot.queryParams
+
+ this.server.getConfig().subscribe(config => {
+ this.requiresApproval = config.signup.requiresApproval
+
+ this.loaded = true
+ })
+
this.userId = queryParams['userId']
+ this.registrationId = queryParams['registrationId']
+
this.verificationString = queryParams['verificationString']
+
this.isPendingEmail = queryParams['isPendingEmail'] === 'true'
- if (!this.userId || !this.verificationString) {
- this.notifier.error($localize`Unable to find user id or verification string.`)
- } else {
- this.verifyEmail()
+ if (!this.verificationString) {
+ this.notifier.error($localize`Unable to find verification string in URL query.`)
+ return
+ }
+
+ if (!this.userId && !this.registrationId) {
+ this.notifier.error($localize`Unable to find user id or registration id in URL query.`)
+ return
}
+
+ this.verifyEmail()
+ }
+
+ isRegistrationRequest () {
+ return !!this.registrationId
+ }
+
+ displaySignupSuccess () {
+ if (!this.success) return false
+ if (!this.isRegistrationRequest() && this.isPendingEmail) return false
+
+ return true
}
verifyEmail () {
- this.userSignupService.verifyEmail(this.userId, this.verificationString, this.isPendingEmail)
+ if (this.isRegistrationRequest()) {
+ return this.verifyRegistrationEmail()
+ }
+
+ return this.verifyUserEmail()
+ }
+
+ private verifyUserEmail () {
+ const options = {
+ userId: this.userId,
+ verificationString: this.verificationString,
+ isPendingEmail: this.isPendingEmail
+ }
+
+ this.signupService.verifyUserEmail(options)
.subscribe({
next: () => {
if (this.authService.isLoggedIn()) {
}
})
}
+
+ private verifyRegistrationEmail () {
+ const options = {
+ registrationId: this.registrationId,
+ verificationString: this.verificationString
+ }
+
+ this.signupService.verifyRegistrationEmail(options)
+ .subscribe({
+ next: () => {
+ this.success = true
+ },
+
+ error: err => {
+ this.failed = true
+
+ this.notifier.error(err.message)
+ }
+ })
+ }
}
import { SharedUsersModule } from '@app/shared/shared-users'
import { SignupMascotComponent } from './signup-mascot.component'
import { SignupStepTitleComponent } from './signup-step-title.component'
-import { SignupSuccessComponent } from './signup-success.component'
+import { SignupSuccessBeforeEmailComponent } from './signup-success-before-email.component'
+import { SignupSuccessAfterEmailComponent } from './signup-success-after-email.component'
+import { SignupService } from './signup.service'
@NgModule({
imports: [
],
declarations: [
- SignupSuccessComponent,
+ SignupSuccessBeforeEmailComponent,
+ SignupSuccessAfterEmailComponent,
SignupStepTitleComponent,
SignupMascotComponent
],
SharedFormModule,
SharedGlobalIconModule,
- SignupSuccessComponent,
+ SignupSuccessBeforeEmailComponent,
+ SignupSuccessAfterEmailComponent,
SignupStepTitleComponent,
SignupMascotComponent
],
providers: [
+ SignupService
]
})
export class SharedSignupModule { }
--- /dev/null
+<my-signup-step-title mascotImageName="success">
+ <strong i18n>Email verified!</strong>
+</my-signup-step-title>
+
+<div class="alert pt-alert-primary">
+ <ng-container *ngIf="requiresApproval">
+ <p i18n>Your email has been verified and your account request has been sent!</p>
+
+ <p i18n>
+ A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
+ </p>
+ </ng-container>
+
+ <ng-container *ngIf="!requiresApproval">
+ <p i18n>Your email has been verified and your account has been created!</p>
+
+ <p i18n>
+ If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
+ </p>
+ </ng-container>
+</div>
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-signup-success-after-email',
+ templateUrl: './signup-success-after-email.component.html',
+ styleUrls: [ './signup-success.component.scss' ]
+})
+export class SignupSuccessAfterEmailComponent {
+ @Input() requiresApproval: boolean
+}
--- /dev/null
+<my-signup-step-title mascotImageName="success">
+ <ng-container *ngIf="requiresApproval">
+ <strong i18n>Account request sent</strong>
+ </ng-container>
+
+ <ng-container *ngIf="!requiresApproval" i18n>
+ <strong>Welcome</strong>
+ <div>on {{ instanceName }}</div>
+ </ng-container>
+</my-signup-step-title>
+
+<div class="alert pt-alert-primary">
+ <p *ngIf="requiresApproval" i18n>Your account request has been sent!</p>
+ <p *ngIf="!requiresApproval" i18n>Your account has been created!</p>
+
+ <ng-container *ngIf="requiresEmailVerification">
+ <p i18n *ngIf="requiresApproval">
+ <strong>Check your emails</strong> to validate your account and complete your registration request.
+ </p>
+
+ <p i18n *ngIf="!requiresApproval">
+ <strong>Check your emails</strong> to validate your account and complete your registration.
+ </p>
+ </ng-container>
+
+ <ng-container *ngIf="!requiresEmailVerification">
+ <p i18n *ngIf="requiresApproval">
+ A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
+ </p>
+
+ <p *ngIf="!requiresApproval" i18n>
+ If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
+ </p>
+ </ng-container>
+</div>
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-signup-success-before-email',
+ templateUrl: './signup-success-before-email.component.html',
+ styleUrls: [ './signup-success.component.scss' ]
+})
+export class SignupSuccessBeforeEmailComponent {
+ @Input() requiresApproval: boolean
+ @Input() requiresEmailVerification: boolean
+ @Input() instanceName: string
+}
+++ /dev/null
-<my-signup-step-title mascotImageName="success" i18n>
- <strong>Welcome</strong>
- <div>on {{ instanceName }}</div>
-</my-signup-step-title>
-
-<div class="alert pt-alert-primary">
- <p i18n>Your account has been created!</p>
-
- <p i18n *ngIf="requiresEmailVerification">
- <strong>Check your emails</strong> to validate your account and complete your inscription.
- </p>
-
- <ng-container *ngIf="!requiresEmailVerification">
- <p i18n>
- If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
- </p>
-
- <p i18n>
- To help moderators and other users to know <strong>who you are</strong>, don't forget to <a class="link-orange" routerLink="/my-account/settings">set up your account profile</a> by adding an <strong>avatar</strong> and a <strong>description</strong>.
- </p>
- </ng-container>
-</div>
+++ /dev/null
-import { Component, Input } from '@angular/core'
-import { ServerService } from '@app/core'
-
-@Component({
- selector: 'my-signup-success',
- templateUrl: './signup-success.component.html',
- styleUrls: [ './signup-success.component.scss' ]
-})
-export class SignupSuccessComponent {
- @Input() requiresEmailVerification: boolean
-
- constructor (private serverService: ServerService) {
-
- }
-
- get instanceName () {
- return this.serverService.getHTMLConfig().instance.name
- }
-}
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, UserService } from '@app/core'
-import { UserRegister } from '@shared/models'
+import { UserRegister, UserRegistrationRequest } from '@shared/models'
@Injectable()
-export class UserSignupService {
+export class SignupService {
+
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor,
private userService: UserService
) { }
- signup (userCreate: UserRegister) {
+ directSignup (userCreate: UserRegister) {
return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
.pipe(
tap(() => this.userService.setSignupInThisSession(true)),
)
}
- verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
- const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
+ requestSignup (userCreate: UserRegistrationRequest) {
+ return this.authHttp.post(UserService.BASE_USERS_URL + 'registrations/request', userCreate)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ // ---------------------------------------------------------------------------
+
+ verifyUserEmail (options: {
+ userId: number
+ verificationString: string
+ isPendingEmail: boolean
+ }) {
+ const { userId, verificationString, isPendingEmail } = options
+
+ const url = `${UserService.BASE_USERS_URL}${userId}/verify-email`
const body = {
verificationString,
isPendingEmail
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
+ verifyRegistrationEmail (options: {
+ registrationId: number
+ verificationString: string
+ }) {
+ const { registrationId, verificationString } = options
+
+ const url = `${UserService.BASE_USERS_URL}registrations/${registrationId}/verify-email`
+ const body = { verificationString }
+
+ return this.authHttp.post(url, body)
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
askSendVerifyEmail (email: string) {
- const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
+ const url = UserService.BASE_USERS_URL + 'ask-send-verify-email'
return this.authHttp.post(url, { email })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
+ // ---------------------------------------------------------------------------
+
getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
// Don't update display name, the user seems to have changed it
if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
this.loadRouteParams()
this.loadRouteQuery()
- this.initHotkeys()
-
this.theaterEnabled = getStoredTheater()
this.hooks.runAction('action:video-watch.init', 'video-watch')
subtitle: queryParams.subtitle,
playerMode: queryParams.mode,
+ playbackRate: queryParams.playbackRate,
peertubeLink: false
}
if (res === false) return this.location.back()
}
+ this.buildHotkeysHelp(video)
+
this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
.catch(err => logger.error('Cannot build the player', err))
muted: urlOptions.muted,
loop: urlOptions.loop,
subtitle: urlOptions.subtitle,
+ playbackRate: urlOptions.playbackRate,
peertubeLink: urlOptions.peertubeLink,
this.video.viewers = newViewers
}
- private initHotkeys () {
+ private buildHotkeysHelp (video: Video) {
+ if (this.hotkeys.length !== 0) {
+ this.hotkeysService.remove(this.hotkeys)
+ }
+
this.hotkeys = [
// These hotkeys are managed by the player
new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`),
new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`),
new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`),
- new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
-
new Hotkey('up', e => e, undefined, $localize`Increase the volume`),
new Hotkey('down', e => e, undefined, $localize`Decrease the volume`),
- new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
- new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
-
- new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
- new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
-
- new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
- new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`),
-
new Hotkey('t', e => {
this.theaterEnabled = !this.theaterEnabled
return false
}, undefined, $localize`Toggle theater mode`)
]
+ if (!video.isLive) {
+ this.hotkeys = this.hotkeys.concat([
+ // These hotkeys are also managed by the player but only for VOD
+
+ new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
+
+ new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
+ new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
+
+ new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
+ new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
+
+ new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
+ new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`)
+ ])
+ }
+
if (this.isUserLoggedIn()) {
this.hotkeys = this.hotkeys.concat([
new Hotkey('shift+s', () => {
case 'best':
return '-hot'
+ case 'name':
+ return 'name'
+
default:
return '-' + algorithm as VideoSortField
}
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Notifier } from '@app/core/notification/notifier.service'
-import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index'
+import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest/rest-extractor.service'
+import { ServerService } from '../server'
import { AuthStatus } from './auth-status.model'
import { AuthUser } from './auth-user.model'
private refreshingTokenObservable: Observable<any>
constructor (
+ private serverService: ServerService,
private http: HttpClient,
private notifier: Notifier,
private hotkeysService: HotkeysService,
const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
- .pipe(
- map(res => this.handleRefreshToken(res)),
- tap(() => {
- this.refreshingTokenObservable = null
- }),
- catchError(err => {
- this.refreshingTokenObservable = null
-
- logger.error(err)
- logger.info('Cannot refresh token -> logout...')
- this.logout()
- this.router.navigate([ '/login' ])
-
- return observableThrowError(() => ({
- error: $localize`You need to reconnect.`
- }))
- }),
- share()
- )
+ .pipe(
+ map(res => this.handleRefreshToken(res)),
+ tap(() => {
+ this.refreshingTokenObservable = null
+ }),
+ catchError(err => {
+ this.refreshingTokenObservable = null
+
+ logger.error(err)
+ logger.info('Cannot refresh token -> logout...')
+ this.logout()
+
+ const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
+ if (externalLoginUrl) window.location.href = externalLoginUrl
+ else this.router.navigate([ '/login' ])
+
+ return observableThrowError(() => ({
+ error: $localize`You need to reconnect.`
+ }))
+ }),
+ share()
+ )
return this.refreshingTokenObservable
}
},
formatHref: {
mention: (href: string) => {
- return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substr(1)
+ return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substring(1)
}
}
}
textMarkdownToHTML (options: {
markdown: string
- withHtml?: boolean
- withEmoji?: boolean
+ withHtml?: boolean // default false
+ withEmoji?: boolean // default false
}) {
const { markdown, withHtml = false, withEmoji = false } = options
enhancedMarkdownToHTML (options: {
markdown: string
- withHtml?: boolean
- withEmoji?: boolean
+ withHtml?: boolean // default false
+ withEmoji?: boolean // default false
}) {
const { markdown, withHtml = false, withEmoji = false } = options
return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
}
+ // ---------------------------------------------------------------------------
+
processVideoTimestamps (videoShortUUID: string, html: string) {
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
if (err.status !== undefined) {
const errorMessage = this.buildServerErrorMessage(err)
- logger.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
+
+ const message = `Backend returned code ${err.status}, errorMessage is: ${errorMessage}`
+
+ if (err.status === HttpStatusCode.NOT_FOUND_404) logger.clientError(message)
+ else logger.error(message)
return errorMessage
}
const debugLogger = debug('peertube:tables:RestTable')
-export abstract class RestTable {
+export abstract class RestTable <T = unknown> {
abstract totalRecords: number
abstract sort: SortMeta
rowsPerPage = this.rowsPerPageOptions[0]
expandedRows = {}
+ selectedRows: T[] = []
+
search: string
protected route: ActivatedRoute
this.reloadData()
}
- protected abstract reloadData (): void
+ isInSelectionMode () {
+ return this.selectedRows.length !== 0
+ }
+
+ protected abstract reloadDataInternal (): void
+
+ protected reloadData () {
+ this.selectedRows = []
+
+ this.reloadDataInternal()
+ }
private getSortLocalStorageKey () {
return 'rest-table-sort-' + this.getIdentifier()
<a i18n *ngIf="!getExternalLoginHref()" routerLink="/login" class="peertube-button-link orange-button">Login</a>
<a i18n *ngIf="getExternalLoginHref()" [href]="getExternalLoginHref()" class="peertube-button-link orange-button">Login</a>
- <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">Create an account</a>
+ <a *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">
+ <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
+ </a>
</div>
<ng-container *ngFor="let menuSection of menuSections" >
import { HotkeysService } from 'angular2-hotkeys'
import * as debug from 'debug'
import { switchMap } from 'rxjs/operators'
+import { environment } from 'src/environments/environment'
import { ViewportScroller } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
return this.languageChooserModal.getCurrentLanguage()
}
+ get requiresApproval () {
+ return this.serverConfig.signup.requiresApproval
+ }
+
ngOnInit () {
this.htmlServerConfig = this.serverService.getHTMLConfig()
this.currentInterfaceLanguage = this.languageChooserModal.getCurrentLanguage()
}
getExternalLoginHref () {
- if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
-
- const externalAuths = this.serverConfig.plugin.registeredExternalAuths
- if (externalAuths.length !== 1) return undefined
-
- return PluginsManager.getExternalAuthHref(externalAuths[0])
+ return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
}
isRegistrationAllowed () {
}
export type BuildFormDefaultValues = {
- [ name: string ]: number | string | string[] | BuildFormDefaultValues
+ [ name: string ]: boolean | number | string | string[] | BuildFormDefaultValues
}
}
}
-export const USER_TERMS_VALIDATOR: BuildFormValidator = {
- VALIDATORS: [ Validators.requiredTrue ],
- MESSAGES: {
- required: $localize`You must agree with the instance terms in order to register on it.`
- }
-}
-
export const USER_BAN_REASON_VALIDATOR: BuildFormValidator = {
VALIDATORS: [
Validators.minLength(3),
<span class="moderation-expanded-text">
<a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reporter:"' + abuse.reporterAccount.displayName + '"' }"
- class="chip"
+ class="chip me-1"
>
<my-actor-avatar size="18" [actor]="abuse.reporterAccount" actorType="account"></my-actor-avatar>
<div>
<span class="moderation-expanded-label" i18n>Reportee</span>
<span class="moderation-expanded-text">
<a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reportee:"' +abuse.flaggedAccount.displayName + '"' }"
- class="chip"
+ class="chip me-1"
>
<my-actor-avatar size="18" [actor]="abuse.flaggedAccount" actorType="account"></my-actor-avatar>
<div>
<div *ngIf="predefinedReasons" class="mt-2 d-flex">
<span>
<a *ngFor="let reason of predefinedReasons" [routerLink]="[ '.' ]"
- [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light"
+ [queryParams]="{ 'search': 'tag:' + reason.id }" class="pt-badge badge-secondary"
>
<div>{{ reason.label }}</div>
</a>
return Actor.IS_LOCAL(abuse.reporterAccount.host)
}
- protected reloadData () {
+ protected reloadDataInternal () {
debugLogger('Loading data.')
const options = {
templateUrl: './custom-markup-container.component.html'
})
export class CustomMarkupContainerComponent implements OnChanges {
- @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement>
+ @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef<HTMLInputElement>
- @Input() content: string
+ @Input() content: string | HTMLDivElement
displayed = false
) { }
async ngOnChanges () {
- await this.buildElement()
+ await this.rebuild()
}
- private async buildElement () {
- if (!this.content) return
+ private async rebuild () {
+ if (this.content instanceof HTMLDivElement) {
+ return this.loadElement(this.content)
+ }
const { rootElement, componentsLoaded } = await this.customMarkupService.buildElement(this.content)
- this.contentWrapper.nativeElement.appendChild(rootElement)
-
await componentsLoaded
+ return this.loadElement(rootElement)
+ }
+
+ private loadElement (el: HTMLDivElement) {
+ this.contentWrapper.nativeElement.appendChild(el)
+
this.displayed = true
}
}
-import { Component, Input } from '@angular/core'
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { VideoChannel } from '../../shared-main'
import { CustomMarkupComponent } from './shared'
@Component({
selector: 'my-button-markup',
templateUrl: 'button-markup.component.html',
- styleUrls: [ 'button-markup.component.scss' ]
+ styleUrls: [ 'button-markup.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class ButtonMarkupComponent implements CustomMarkupComponent {
@Input() theme: 'primary' | 'secondary'
import { from } from 'rxjs'
import { finalize, map, switchMap, tap } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { MarkdownService, Notifier, UserService } from '@app/core'
import { FindInBulkService } from '@app/shared/shared-search'
import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-channel-miniature-markup',
templateUrl: 'channel-miniature-markup.component.html',
- styleUrls: [ 'channel-miniature-markup.component.scss' ]
+ styleUrls: [ 'channel-miniature-markup.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
@Input() name: string
import { finalize } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { Notifier } from '@app/core'
import { FindInBulkService } from '@app/shared/shared-search'
import { MiniatureDisplayOptions } from '../../shared-video-miniature'
@Component({
selector: 'my-playlist-miniature-markup',
templateUrl: 'playlist-miniature-markup.component.html',
- styleUrls: [ 'playlist-miniature-markup.component.scss' ]
+ styleUrls: [ 'playlist-miniature-markup.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
@Input() uuid: string
import { finalize } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { AuthService, Notifier } from '@app/core'
import { FindInBulkService } from '@app/shared/shared-search'
import { Video } from '../../shared-main'
@Component({
selector: 'my-video-miniature-markup',
templateUrl: 'video-miniature-markup.component.html',
- styleUrls: [ 'video-miniature-markup.component.scss' ]
+ styleUrls: [ 'video-miniature-markup.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
@Input() uuid: string
import { finalize } from 'rxjs/operators'
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { AuthService, Notifier } from '@app/core'
import { VideoSortField } from '@shared/models'
import { Video, VideoService } from '../../shared-main'
@Component({
selector: 'my-videos-list-markup',
templateUrl: 'videos-list-markup.component.html',
- styleUrls: [ 'videos-list-markup.component.scss' ]
+ styleUrls: [ 'videos-list-markup.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit {
@Input() sort: string
@Input() markdownType: 'text' | 'enhanced' = 'text'
@Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
+ @Input() debounceTime = 150
+
@Input() markdownVideo: Video
@Input() name = 'description'
ngOnInit () {
this.contentChanged
.pipe(
- debounceTime(150),
+ debounceTime(this.debounceTime),
distinctUntilChanged()
)
.subscribe(() => this.updatePreviews())
</tr>
<tr>
- <th i18n class="label" scope="row">User registration allowed</th>
- <td>
- <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean>
- </td>
+ <th i18n class="label" scope="row">User registration</th>
+
+ <td class="value">{{ buildRegistrationLabel() }}</td>
</tr>
<tr>
if (policy === 'display') return $localize`Displayed`
}
+ buildRegistrationLabel () {
+ const config = this.serverConfig.signup
+
+ if (config.allowed !== true) return $localize`Disabled`
+ if (config.requiresApproval === true) return $localize`Requires approval by moderators`
+
+ return $localize`Enabled`
+ }
+
getServerVersionAndCommit () {
return this.serverService.getServerVersionAndCommit()
}
import { About } from '@shared/models'
import { environment } from '../../../environments/environment'
+export type AboutHTML = Pick<About['instance'],
+'terms' | 'codeOfConduct' | 'moderationInformation' | 'administrator' | 'creationReason' |
+'maintenanceLifetime' | 'businessModel' | 'hardwareInformation'
+>
+
@Injectable()
export class InstanceService {
private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
}
async buildHtml (about: About) {
- const html = {
+ const html: AboutHTML = {
terms: '',
codeOfConduct: '',
moderationInformation: '',
export * from './account.model'
export * from './account.service'
export * from './actor.model'
+export * from './signup-label.component'
--- /dev/null
+<ng-container i18n *ngIf="requiresApproval">Request an account</ng-container>
+<ng-container i18n *ngIf="!requiresApproval">Create an account</ng-container>
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-signup-label',
+ templateUrl: './signup-label.component.html'
+})
+export class SignupLabelComponent {
+ @Input() requiresApproval: boolean
+}
import { LoadingBarModule } from '@ngx-loading-bar/core'
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
import { SharedGlobalIconModule } from '../shared-icons'
-import { AccountService } from './account'
+import { AccountService, SignupLabelComponent } from './account'
import {
AutofocusDirective,
BytesPipe,
UserQuotaComponent,
UserNotificationsComponent,
+ SignupLabelComponent,
+
EmbedComponent,
PluginPlaceholderComponent,
UserQuotaComponent,
UserNotificationsComponent,
+ SignupLabelComponent,
+
EmbedComponent,
PluginPlaceholderComponent,
latestVersion: string
}
+ registration?: {
+ id: number
+ username: string
+ }
+
createdAt: string
updatedAt: string
accountUrl?: string
+ registrationsUrl?: string
+
videoImportIdentifier?: string
videoImportUrl?: string
this.plugin = hash.plugin
this.peertube = hash.peertube
+ this.registration = hash.registration
this.createdAt = hash.createdAt
this.updatedAt = hash.updatedAt
this.accountUrl = this.buildAccountUrl(this.account)
break
+ case UserNotificationType.NEW_USER_REGISTRATION_REQUEST:
+ this.registrationsUrl = '/admin/moderation/registrations/list'
+ break
+
case UserNotificationType.NEW_FOLLOW:
this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
break
</div>
</ng-container>
+ <ng-container *ngSwitchCase="20"> <!-- UserNotificationType.NEW_USER_REGISTRATION_REQUEST -->
+ <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ User <a (click)="markAsRead(notification)" [routerLink]="notification.registrationsUrl">{{ notification.registration.username }}</a> wants to register on your instance
+ </div>
+ </ng-container>
+
<ng-container *ngSwitchDefault>
<my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
@use '_variables' as *;
@use '_mixins' as *;
-.chip {
- @include chip;
-}
-
.unblock-button {
@include peertube-button;
@include grey-button;
)
}
- protected reloadData () {
+ protected reloadDataInternal () {
const operation = this.mode === BlocklistComponentType.Account
? this.blocklistService.getUserAccountBlocklist({
pagination: this.pagination,
}
}
-.chip {
- @include chip;
-}
-
my-action-dropdown.show {
::ng-deep .dropdown-root {
display: block !important;
.block-button {
@include create-button;
}
-
-.chip {
- @include chip;
-}
})
}
- protected reloadData () {
+ protected reloadDataInternal () {
const operation = this.mode === BlocklistComponentType.Account
? this.blocklistService.getUserServerBlocklist({
pagination: this.pagination,
const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`)
if (res === false) return
- this.userAdminService.removeUser(user)
+ this.userAdminService.removeUsers(user)
.subscribe({
next: () => {
this.notifier.success($localize`User ${user.username} deleted.`)
export * from './user-admin.service'
-export * from './user-signup.service'
export * from './two-factor.service'
export * from './shared-users.module'
-
import { NgModule } from '@angular/core'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { TwoFactorService } from './two-factor.service'
import { UserAdminService } from './user-admin.service'
-import { UserSignupService } from './user-signup.service'
@NgModule({
imports: [
exports: [],
providers: [
- UserSignupService,
UserAdminService,
TwoFactorService
]
)
}
- removeUser (usersArg: UserServerModel | UserServerModel[]) {
+ removeUsers (usersArg: UserServerModel | UserServerModel[]) {
const users = arrayify(usersArg)
return from(users)
<ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
</div>
- <div *ngIf="containedInPlaylists" class="video-contained-in-playlists">
- <a *ngFor="let playlist of containedInPlaylists" class="chip rectangular bg-secondary text-light" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
+ <div *ngIf="containedInPlaylists" class="fs-6">
+ <a *ngFor="let playlist of containedInPlaylists" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
{{ playlist.playlistDisplayName }}
</a>
</div>
$more-button-width: 40px;
-.chip {
- @include chip;
-}
-
.video-miniature {
font-size: 14px;
}
this.cd.markForCheck()
})
- this.videoPlaylistService.runPlaylistCheck(this.video.id)
+ this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
}
}
import * as debug from 'debug'
import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
-import { debounceTime, switchMap } from 'rxjs/operators'
+import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import {
private lastQueryLength: number
+ private videoRequests = new Subject<{ reset: boolean, obs: Observable<ResultList<Video>> }>()
+
constructor (
private notifier: Notifier,
private authService: AuthService,
}
ngOnInit () {
+ this.subscribeToVideoRequests()
+
const hiddenFilters = this.hideScopeFilter
? [ 'scope' ]
: []
}
loadMoreVideos (reset = false) {
- if (reset) this.hasDoneFirstQuery = false
-
- this.getVideosObservableFunction(this.pagination, this.filters)
- .subscribe({
- next: ({ data }) => {
- this.hasDoneFirstQuery = true
- this.lastQueryLength = data.length
-
- if (reset) this.videos = []
- this.videos = this.videos.concat(data)
-
- if (this.groupByDate) this.buildGroupedDateLabels()
-
- this.onDataSubject.next(data)
- this.videosLoaded.emit(this.videos)
- },
-
- error: err => {
- const message = $localize`Cannot load more videos. Try again later.`
+ if (reset) {
+ this.hasDoneFirstQuery = false
+ this.videos = []
+ }
- logger.error(message, err)
- this.notifier.error(message)
- }
- })
+ this.videoRequests.next({ reset, obs: this.getVideosObservableFunction(this.pagination, this.filters) })
}
reloadVideos () {
this.onFiltersChanged(true)
})
}
+
+ private subscribeToVideoRequests () {
+ this.videoRequests
+ .pipe(concatMap(({ reset, obs }) => obs.pipe(map(({ data }) => ({ data, reset })))))
+ .subscribe({
+ next: ({ data, reset }) => {
+ this.hasDoneFirstQuery = true
+ this.lastQueryLength = data.length
+
+ if (reset) this.videos = []
+ this.videos = this.videos.concat(data)
+
+ if (this.groupByDate) this.buildGroupedDateLabels()
+
+ this.onDataSubject.next(data)
+ this.videosLoaded.emit(this.videos)
+ },
+
+ error: err => {
+ const message = $localize`Cannot load more videos. Try again later.`
+
+ logger.error(message, err)
+ this.notifier.error(message)
+ }
+ })
+ }
}
.subscribe(result => {
this.playlistsData = result.data
- this.videoPlaylistService.runPlaylistCheck(this.video.id)
+ this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
})
this.videoPlaylistSearchChanged
.subscribe(playlistsResult => {
this.playlistsData = playlistsResult.data
- this.videoPlaylistService.runPlaylistCheck(this.video.id)
+ this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
})
}
stopTimestamp: body.stopTimestamp
})
- this.runPlaylistCheck(body.videoId)
+ this.runVideoExistsInPlaylistCheck(body.videoId)
+
+ if (this.myAccountPlaylistCache) {
+ const playlist = this.myAccountPlaylistCache.data.find(p => p.id === playlistId)
+ if (!playlist) return
+
+ const otherPlaylists = this.myAccountPlaylistCache.data.filter(p => p !== playlist)
+ this.myAccountPlaylistCache.data = [ playlist, ...otherPlaylists ]
+ }
}),
catchError(err => this.restExtractor.handleError(err))
)
elem.stopTimestamp = body.stopTimestamp
}
- this.runPlaylistCheck(videoId)
+ this.runVideoExistsInPlaylistCheck(videoId)
}),
catchError(err => this.restExtractor.handleError(err))
)
.filter(e => e.playlistElementId !== playlistElementId)
}
- this.runPlaylistCheck(videoId)
+ this.runVideoExistsInPlaylistCheck(videoId)
}),
catchError(err => this.restExtractor.handleError(err))
)
return obs
}
- runPlaylistCheck (videoId: number) {
+ runVideoExistsInPlaylistCheck (videoId: number) {
debugLogger('Running playlist check.')
if (this.videoExistsCache[videoId]) {
import './shared/control-bar/peertube-link-button'
import './shared/control-bar/peertube-load-progress-bar'
import './shared/control-bar/theater-button'
+import './shared/control-bar/peertube-live-display'
import './shared/settings/resolution-menu-button'
import './shared/settings/resolution-menu-item'
import './shared/settings/settings-dialog'
videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
const player = this
+ if (!isNaN(+options.common.playbackRate)) {
+ player.playbackRate(+options.common.playbackRate)
+ }
+
let alreadyFallback = false
const handleError = () => {
self.addContextMenu(videojsOptionsBuilder, player, options.common)
if (isMobile()) player.peertubeMobile()
- if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin()
+ if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
player.bezels()
export * from './next-previous-video-button'
export * from './p2p-info-button'
export * from './peertube-link-button'
+export * from './peertube-live-display'
export * from './peertube-load-progress-bar'
export * from './theater-button'
--- /dev/null
+import videojs from 'video.js'
+import { PeerTubeLinkButtonOptions } from '../../types'
+
+const ClickableComponent = videojs.getComponent('ClickableComponent')
+
+class PeerTubeLiveDisplay extends ClickableComponent {
+ private interval: any
+
+ private contentEl_: any
+
+ constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
+ super(player, options as any)
+
+ this.interval = this.setInterval(() => this.updateClass(), 1000)
+
+ this.show()
+ this.updateSync(true)
+ }
+
+ dispose () {
+ if (this.interval) {
+ this.clearInterval(this.interval)
+ this.interval = undefined
+ }
+
+ this.contentEl_ = null
+
+ super.dispose()
+ }
+
+ createEl () {
+ const el = super.createEl('div', {
+ className: 'vjs-live-control vjs-control'
+ })
+
+ this.contentEl_ = videojs.dom.createEl('div', {
+ className: 'vjs-live-display'
+ }, {
+ 'aria-live': 'off'
+ })
+
+ this.contentEl_.appendChild(videojs.dom.createEl('span', {
+ className: 'vjs-control-text',
+ textContent: `${this.localize('Stream Type')}\u00a0`
+ }))
+
+ this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')))
+
+ el.appendChild(this.contentEl_)
+ return el
+ }
+
+ handleClick () {
+ const hlsjs = this.getHLSJS()
+ if (!hlsjs) return
+
+ this.player().currentTime(hlsjs.liveSyncPosition)
+ this.player().play()
+ this.updateSync(true)
+ }
+
+ private updateClass () {
+ const hlsjs = this.getHLSJS()
+ if (!hlsjs) return
+
+ // Not loaded yet
+ if (this.player().currentTime() === 0) return
+
+ const isSync = Math.abs(this.player().currentTime() - hlsjs.liveSyncPosition) < 10
+ this.updateSync(isSync)
+ }
+
+ private updateSync (isSync: boolean) {
+ if (isSync) {
+ this.addClass('synced-with-live-edge')
+ this.removeAttribute('title')
+ this.disable()
+ } else {
+ this.removeClass('synced-with-live-edge')
+ this.setAttribute('title', this.localize('Go back to the live'))
+ this.enable()
+ }
+ }
+
+ private getHLSJS () {
+ const p2pMediaLoader = this.player()?.p2pMediaLoader
+ if (!p2pMediaLoader) return undefined
+
+ return p2pMediaLoader().getHLSJS()
+ }
+}
+
+videojs.registerComponent('PeerTubeLiveDisplay', PeerTubeLiveDisplay)
const Plugin = videojs.getPlugin('plugin')
+export type HotkeysOptions = {
+ isLive: boolean
+}
+
class PeerTubeHotkeysPlugin extends Plugin {
private static readonly VOLUME_STEP = 0.1
private static readonly SEEK_STEP = 5
private readonly handlers: KeyHandler[]
- constructor (player: videojs.Player, options: videojs.PlayerOptions) {
+ private readonly isLive: boolean
+
+ constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
super(player, options)
+ this.isLive = options.isLive
+
this.handlers = this.buildHandlers()
this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
}
},
- // Rewind
- {
- accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
- cb: e => {
- e.preventDefault()
-
- const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
- this.player.currentTime(target)
- }
- },
-
- // Forward
- {
- accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
- cb: e => {
- e.preventDefault()
-
- const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
- this.player.currentTime(target)
- }
- },
-
// Fullscreen
{
// f key or Ctrl + Enter
{
accept: e => e.key === '>',
cb: () => {
+ if (this.isLive) return
+
const target = Math.min(this.player.playbackRate() + 0.1, 5)
this.player.playbackRate(parseFloat(target.toFixed(2)))
{
accept: e => e.key === '<',
cb: () => {
+ if (this.isLive) return
+
const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
this.player.playbackRate(parseFloat(target.toFixed(2)))
{
accept: e => e.key === ',',
cb: () => {
+ if (this.isLive) return
+
this.player.pause()
// Calculate movement distance (assuming 30 fps)
{
accept: e => e.key === '.',
cb: () => {
+ if (this.isLive) return
+
this.player.pause()
// Calculate movement distance (assuming 30 fps)
}
]
+ if (this.isLive) return handlers
+
+ return handlers.concat(this.buildVODHandlers())
+ }
+
+ private buildVODHandlers () {
+ const handlers: KeyHandler[] = [
+ // Rewind
+ {
+ accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
+ cb: e => {
+ if (this.isLive) return
+
+ e.preventDefault()
+
+ const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
+ this.player.currentTime(target)
+ }
+ },
+
+ // Forward
+ {
+ accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
+ cb: e => {
+ if (this.isLive) return
+
+ e.preventDefault()
+
+ const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
+ this.player.currentTime(target)
+ }
+ }
+ ]
+
// 0-9 key handlers
for (let i = 0; i < 10; i++) {
handlers.push({
accept: e => this.isNakedOrShift(e, i + ''),
cb: e => {
+ if (this.isLive) return
+
e.preventDefault()
this.player.currentTime(this.player.duration() * i * 0.1)
}
Object.assign(children, {
- currentTimeDisplay: {},
- timeDivider: {},
- durationDisplay: {},
- liveDisplay: {},
+ ...this.getTimeControls(),
flexibleWidthSpacer: {},
private getSettingsButton () {
const settingEntries: string[] = []
- settingEntries.push('playbackRateMenuButton')
+ if (!this.options.isLive) {
+ settingEntries.push('playbackRateMenuButton')
+ }
if (this.options.captions === true) settingEntries.push('captionsButton')
}
}
+ private getTimeControls () {
+ if (this.options.isLive) {
+ return {
+ peerTubeLiveDisplay: {}
+ }
+ }
+
+ return {
+ currentTimeDisplay: {},
+ timeDivider: {},
+ durationDisplay: {}
+ }
+ }
+
private getProgressControl () {
+ if (this.options.isLive) return {}
+
const loadProgressBar = this.mode === 'webtorrent'
? 'peerTubeLoadProgressBar'
: 'loadProgressBar'
if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
else this.errorCounts[data.type] = 1
- if (data.fatal) logger.warn(error.message)
- else logger.error(error.message, { data })
+ if (data.fatal) logger.error(error.message, { currentTime: this.player.currentTime(), data })
+ else logger.warn(error.message)
if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
error.code = 2
let colorSpace = 'unknown'
let codecs = 'unknown'
- if (metadata?.streams[0]) {
+ if (metadata?.streams?.[0]) {
const stream = metadata.streams[0]
colorSpace = stream['color_space'] !== 'unknown'
}
const resolution = videoFile?.resolution.label + videoFile?.fps
- const buffer = this.timeRangesToString(this.player().buffered())
+ const buffer = this.timeRangesToString(this.player_.buffered())
const progress = this.player_.webtorrent().getTorrent()?.progress
return {
resume?: string
peertubeLink: boolean
+
+ playbackRate?: number | string
}
export interface CommonOptions extends CustomizationOptions {
import { Engine } from '@peertube/p2p-media-loader-hlsjs'
import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
+import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin'
import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
bezels (): void
peertubeMobile (): void
- peerTubeHotkeysPlugin (): void
+ peerTubeHotkeysPlugin (options?: HotkeysOptions): void
stats (options?: StatsCardOptions): StatsForNerdsPlugin
warn (message: LoggerMessage, meta?: LoggerMeta) {
this.runHooks('warn', message, meta)
+ this.clientWarn(message, meta)
+ }
+
+ clientWarn (message: LoggerMessage, meta?: LoggerMeta) {
if (meta) console.warn(message, meta)
else console.warn(message)
}
error (message: LoggerMessage, meta?: LoggerMeta) {
this.runHooks('error', message, meta)
+ this.clientError(message, meta)
+ }
+
+ clientError (message: LoggerMessage, meta?: LoggerMeta) {
if (meta) console.error(message, meta)
else console.error(message)
}
import { firstValueFrom, ReplaySubject } from 'rxjs'
import { first, shareReplay } from 'rxjs/operators'
import { RegisterClientHelpers } from 'src/types/register-client-option.model'
-import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
+import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
import {
ClientHookName,
clientHookObject,
RegisterClientRouteOptions,
RegisterClientSettingsScriptOptions,
RegisterClientVideoFieldOptions,
- RegisteredExternalAuthConfig,
ServerConfigPlugin
} from '@shared/models'
import { environment } from '../environments/environment'
return isTheme ? '/themes' : '/plugins'
}
- static getExternalAuthHref (auth: RegisteredExternalAuthConfig) {
- return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+ static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
+ if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
+ const externalAuths = serverConfig.plugin.registeredExternalAuths
+ if (externalAuths.length !== 1) return undefined
+
+ return getExternalAuthHref(apiUrl, externalAuths[0])
}
loadPluginsList (config: HTMLServerConfig) {
border: 2px solid pvar(--mainColorLightest);
}
}
+
+// ---------------------------------------------------------------------------
+
+.chip {
+ @include chip;
+}
font-weight: $font-semibold;
line-height: 1.1;
+ &.badge-fs-normal {
+ font-size: 100%;
+ }
+
&.badge-primary {
color: pvar(--mainBackgroundColor);
background-color: pvar(--mainColor);
font-display: swap;
src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2');
}
-
-@mixin muted {
- color: pvar(--greyForegroundColor) !important;
-}
max-height: $font-size * $number-of-lines;
}
+@mixin muted {
+ color: pvar(--greyForegroundColor) !important;
+}
+
@mixin fade-text ($fade-after, $background-color) {
position: relative;
overflow: hidden;
}
@mixin chip {
- --chip-radius: 5rem;
- --chip-padding: .2rem .4rem;
- $avatar-height: 1.2rem;
+ --avatar-size: 1.2rem;
- align-items: center;
- border-radius: var(--chip-radius);
display: inline-flex;
- font-size: 90%;
color: pvar(--mainForegroundColor);
- height: $avatar-height;
- line-height: 1rem;
- margin: .1rem;
+ height: var(--avatar-size);
max-width: 320px;
overflow: hidden;
- padding: var(--chip-padding);
text-decoration: none;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
- &.rectangular {
- --chip-radius: .2rem;
- --chip-padding: .2rem .3rem;
- }
-
my-actor-avatar {
- @include margin-left(-.4rem);
@include margin-right(.2rem);
+
+ border-radius: 5rem;
+ width: var(--avatar-size);
+ height: var(--avatar-size);
}
&.two-lines {
- $avatar-height: 2rem;
+ --avatar-size: 2rem;
- height: $avatar-height;
+ font-size: 14px;
+ line-height: 1rem;
my-actor-avatar {
display: inline-block;
}
- div {
- margin: 0 .1rem;
-
+ > div {
display: flex;
flex-direction: column;
- height: $avatar-height;
justify-content: center;
}
}
}
.vjs-live-control {
- line-height: $control-bar-height;
- min-width: 4em;
+ padding: 5px 7px;
+ border-radius: 3px;
+ height: fit-content;
+ margin: auto 10px;
+ font-weight: bold;
+ max-width: fit-content;
+ opacity: 1 !important;
+ line-height: normal;
+ position: relative;
+ top: -1px;
+
+ &.synced-with-live-edge {
+ background: #d7281c;
+ }
+
+ &:not(.synced-with-live-edge) {
+ cursor: pointer;
+ background: #80807f;
+ }
}
.vjs-peertube {
body .p-datepicker table {
font-size: 14px;
margin: 0.857em 0 0 0;
+ table-layout: fixed;
}
body .p-datepicker table th {
padding: 0.5em;
private enableApi = false
private startTime: number | string = 0
private stopTime: number | string
+ private playbackRate: number | string
private title: boolean
private warningTitle: boolean
this.subtitle = getParamString(params, 'subtitle')
this.startTime = getParamString(params, 'start')
this.stopTime = getParamString(params, 'stop')
+ this.playbackRate = getParamString(params, 'playbackRate')
this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
this.foregroundColor = getParamString(params, 'foregroundColor')
? playlistTracker.getCurrentElement().stopTimestamp
: this.stopTime,
+ playbackRate: this.playbackRate,
+
videoCaptions,
inactivityTimeout: 2500,
videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
dependencies:
tslib "^2.3.0"
+"@arr/every@^1.0.0":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@arr/every/-/every-1.0.1.tgz#22fe1f8e6355beca6c7c7bde965eb15cf994387b"
+ integrity sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==
+
"@assemblyscript/loader@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06"
read-package-json-fast "^2.0.3"
which "^2.0.2"
-"@peertube/p2p-media-loader-core@^1.0.13", "@peertube/p2p-media-loader-core@^1.0.8":
- version "1.0.13"
- resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.13.tgz#36744a291b69c001b2562c1a93017979f8534ff8"
- integrity sha512-ArSAaeuxwwBAG0Xd3Gj0TzKObLfJFYzHz9+fREvmUf+GZQEG6qGwWmrdVWL6xjPiEuo6LdFeCOnHSQzAbj/ptg==
+"@peertube/maildev@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@peertube/maildev/-/maildev-1.2.0.tgz#f25ee9fa6a45c0a6bc99c5392f63139eaa8eb088"
+ integrity sha512-VGog0A2gk0P8UnP0ZjCoYQumELiqqQY5i+gt18avTC7NJNJLUxMRMI045NAVSDFVbqt2EJJPsbZf3LFjUWRtmw==
+ dependencies:
+ async "^3.1.0"
+ commander "^8.3.0"
+ mailparser-mit "^1.0.0"
+ rimraf "^3.0.2"
+ smtp-server "^3.9.0"
+ wildstring "1.0.9"
+
+"@peertube/p2p-media-loader-core@^1.0.14":
+ version "1.0.14"
+ resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.14.tgz#b4442dd343d6b30a51502e1240275eb98ef2c788"
+ integrity sha512-tjQv1CNziNY+zYzcL1h4q6AA2WuBUZnBIeVyjWR/EsO1EEC1VMdvPsL02cqYLz9yvIxgycjeTsWCm6XDqNgXRw==
dependencies:
bittorrent-tracker "^9.19.0"
debug "^4.3.4"
sha.js "^2.4.11"
simple-peer "^9.11.1"
-"@peertube/p2p-media-loader-hlsjs@^1.0.13":
- version "1.0.13"
- resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.13.tgz#5305e2008041d01850802544d1c49298f79dd67a"
- integrity sha512-2BO2oaRsSHEhLkgi2iw1r4n1Yqq1EnyoOgOZccPDqjmHUsZSV/wNrno8WYr6LsleudrHA26Imu57hVD1jDx7lg==
+"@peertube/p2p-media-loader-hlsjs@^1.0.14":
+ version "1.0.14"
+ resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.14.tgz#829629a57608b0e30f4b50bc98578e6bee9f8b9b"
+ integrity sha512-ySUVgUvAFXCE5E94xxjfywQ8xzk3jy9UGVkgi5Oqq+QeY7uG+o7CZ+LsQ/RjXgWBD70tEnyyfADHtL+9FCnwyQ==
dependencies:
- "@peertube/p2p-media-loader-core" "^1.0.8"
+ "@peertube/p2p-media-loader-core" "^1.0.14"
debug "^4.3.4"
events "^3.3.0"
m3u8-parser "^4.7.1"
tokenizr "^1.6.4"
xmldom "^0.6.0"
+"@polka/parse@^1.0.0-next.0":
+ version "1.0.0-next.0"
+ resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
+ integrity sha512-zcPNrc3PNrRLSCQ7ca8XR7h18VxdPIXhn+yvrYMdUFCHM7mhXGSPw5xBdbcf/dQ1cI4uE8pDfmm5uU+HX+WfFg==
+
+"@polka/url@^0.5.0":
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/@polka/url/-/url-0.5.0.tgz#b21510597fd601e5d7c95008b76bf0d254ebfd31"
+ integrity sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==
+
"@polka/url@^1.0.0-next.20":
version "1.0.0-next.21"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
dependencies:
"@types/node" "*"
+"@types/gitconfiglocal@^2.0.1":
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/@types/gitconfiglocal/-/gitconfiglocal-2.0.1.tgz#c134f9fb03d71917afa35c14f3b82085520509a6"
+ integrity sha512-AYC38la5dRwIfbrZhPNIvlGHlIbH+kdl2j8A37twoCQyhKPPoRPfVmoBZKajpLIfV7SMboU6MZ6w/RmZLH68IQ==
+
"@types/html-minifier-terser@^6.0.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
"@types/lodash" "*"
"@types/lodash@*":
- version "4.14.189"
- resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.189.tgz#975ff8c38da5ae58b751127b19ad5e44b5b7f6d2"
- integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA==
+ version "4.14.191"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
+ integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
"@types/magnet-uri@*":
version "5.1.3"
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
"@types/mocha@^10.0.0":
- version "10.0.0"
- resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.0.tgz#3d9018c575f0e3f7386c1de80ee66cc21fbb7a52"
- integrity sha512-rADY+HtTOA52l9VZWtgQfn4p+UDVM2eDVkMZT1I6syp0YKxW2F9v+0pbRZLsvskhQv/vMb6ZfCay81GHbz5SHg==
+ version "10.0.1"
+ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b"
+ integrity sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==
"@types/mousetrap@^1.6.9":
version "1.6.11"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node@*", "@types/node@^18.0.0":
- version "18.11.9"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
- integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
+ version "18.11.18"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
+ integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
"@types/node@^17.0.42":
version "17.0.45"
integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==
"@types/yargs@^17.0.8":
- version "17.0.13"
- resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76"
- integrity sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==
+ version "17.0.19"
+ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.19.tgz#8dbecdc9ab48bee0cb74f6e3327de3fa0d0c98ae"
+ integrity sha512-cAx3qamwaYX9R0fzOIZAlFpo4A+1uBVCxqpKz9D26uTF4srRXaGTTsikQmaotCtNdbhzyUH7ft6p9ktz9s6UNQ==
dependencies:
"@types/yargs-parser" "*"
is-function "^1.0.1"
"@wdio/browserstack-service@^7.25.2":
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.26.0.tgz#d303c5998e565734bd7f5c23fc9b291a588b7c21"
- integrity sha512-hRKmg4u/DRNZm1EJGaYESAH6GsCPCtBm15fP9ngm/HFUG084thFfrD8Tt09hO+KSNoK4tXl4k1ZHZ4akrOq9KA==
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.29.1.tgz#46282aa07b7c11a51ebac0bff1f12f1badd6e264"
+ integrity sha512-1+MoqlIXIjbh1oEOZcvtemij+Yz/CB6orZjeT3WCoA9oY8Ul8EeIHhfF7GxmE6u0OVofjmC+wfO5NlHYCKgL1w==
dependencies:
- "@types/node" "^18.0.0"
+ "@types/gitconfiglocal" "^2.0.1"
"@wdio/logger" "7.26.0"
+ "@wdio/reporter" "7.25.4"
"@wdio/types" "7.26.0"
browserstack-local "^1.4.5"
form-data "^4.0.0"
+ git-repo-info "^2.1.1"
+ gitconfiglocal "^2.1.0"
got "^11.0.2"
- webdriverio "7.26.0"
+ uuid "^8.3.2"
+ webdriverio "7.29.1"
"@wdio/cli@^7.25.2":
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.26.0.tgz#20c690a5ede4a35cb2f84da9041c250a6013bc54"
- integrity sha512-xG+ZIzPqzz/Tvhfrogd8oNvTXzzdE+cbkmTHjMGo1hnmnoAQPeAEcV/QqaX5CHFE9DjaguEeadqjcZikB5U2GQ==
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.29.1.tgz#1b47f5a45f21754d42be814dbae94ff723a6a1a2"
+ integrity sha512-dldHNYlnuFUG10TlENbeL41tujqgYD7S/9nzV1J/szBryCO6AIVz/QWn/AUv3zrsO2sn8TNF8BMEXRvLgCxyeg==
dependencies:
"@types/ejs" "^3.0.5"
"@types/fs-extra" "^9.0.4"
"@types/recursive-readdir" "^2.2.0"
"@wdio/config" "7.26.0"
"@wdio/logger" "7.26.0"
- "@wdio/protocols" "7.22.0"
+ "@wdio/protocols" "7.27.0"
"@wdio/types" "7.26.0"
"@wdio/utils" "7.26.0"
async-exit-hook "^2.0.1"
lodash.union "^4.6.0"
mkdirp "^1.0.4"
recursive-readdir "^2.2.2"
- webdriverio "7.26.0"
+ webdriverio "7.29.1"
yargs "^17.0.0"
yarn-install "^1.0.0"
glob "^8.0.3"
"@wdio/local-runner@^7.25.2":
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.26.0.tgz#a056c6e9d73c7f48e54fe3f07ce573a90dae26ab"
- integrity sha512-GdCP7Y8s8qvoctC0WaSGBSmTSbVw74WEJm6Y3n3DpoCI8ABFNkQlhFlqJH+taQDs3sRVEM65bHGcU4C4FOVWXQ==
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.29.1.tgz#f93a2953847b4271b59ba1b9635920e8046f0e55"
+ integrity sha512-4w9Dsp9/4+MEU8yG7M8ynsCqpSP6UbKqZ2M/gWpvkvy57rb3eS9evFdIFfRzuQmbsztG9qeAlGILwlZ4/oaopg==
dependencies:
"@types/stream-buffers" "^3.0.3"
"@wdio/logger" "7.26.0"
"@wdio/repl" "7.26.0"
- "@wdio/runner" "7.26.0"
+ "@wdio/runner" "7.29.1"
"@wdio/types" "7.26.0"
async-exit-hook "^2.0.1"
split2 "^4.0.0"
expect-webdriverio "^3.0.0"
mocha "^10.0.0"
-"@wdio/protocols@7.22.0":
- version "7.22.0"
- resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.22.0.tgz#d89faef687cb08981d734bbc5e5dffc6fb5a064c"
- integrity sha512-8EXRR+Ymdwousm/VGtW3H1hwxZ/1g1H99A1lF0U4GuJ5cFWHCd0IVE5H31Z52i8ZruouW8jueMkGZPSo2IIUSQ==
+"@wdio/protocols@7.27.0":
+ version "7.27.0"
+ resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.27.0.tgz#8e2663ec877dce7a5f76b021209c18dd0132e853"
+ integrity sha512-hT/U22R5i3HhwPjkaKAG0yd59eaOaZB0eibRj2+esCImkb5Y6rg8FirrlYRxIGFVBl0+xZV0jKHzR5+o097nvg==
"@wdio/repl@7.26.0":
version "7.26.0"
dependencies:
"@wdio/utils" "7.26.0"
-"@wdio/reporter@7.26.0":
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.26.0.tgz#26c0e7114a4c1e7b29a79e4d178e5312e04d7934"
- integrity sha512-kEb7i1A4V4E1wJgdyvLsDbap4cEp1fPZslErGtbAbK+9HI8Lt/SlTZCiOpZbvhgzvawEqOV6UqxZT1RsL8wZWw==
+"@wdio/reporter@7.25.4":
+ version "7.25.4"
+ resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.25.4.tgz#b6a69652dd0c4ec131255000af128eac403a18b9"
+ integrity sha512-M37qzEmF5qNffyZmRQGjDlrXqWW21EFvgW8wsv1b/NtfpZc0c0MoRpeh6BnvX1KcE4nCXfjXgSJPOqV4ZCzUEQ==
+ dependencies:
+ "@types/diff" "^5.0.0"
+ "@types/node" "^18.0.0"
+ "@types/object-inspect" "^1.8.0"
+ "@types/supports-color" "^8.1.0"
+ "@types/tmp" "^0.2.0"
+ "@wdio/types" "7.25.4"
+ diff "^5.0.0"
+ fs-extra "^10.0.0"
+ object-inspect "^1.10.3"
+ supports-color "8.1.1"
+
+"@wdio/reporter@7.29.1":
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.29.1.tgz#7fc2e3b7aa3843172dcd97221c44257384cbbd27"
+ integrity sha512-mpusCpbw7RxnJSDu9qa1qv5IfEMCh7377y1Typ4J2TlMy+78CQzGZ8coEXjBxLcqijTUwcyyoLNI5yRSvbDExw==
dependencies:
"@types/diff" "^5.0.0"
"@types/node" "^18.0.0"
object-inspect "^1.10.3"
supports-color "8.1.1"
-"@wdio/runner@7.26.0":
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.26.0.tgz#c0b2848dc885b655e8690d3e0381dfb0ad221af5"
- integrity sha512-DhQiOs10oPeLlv7/R+997arPg5OY7iEgespGkn6r+kdx2o+awxa6PFegQrjJmRKUmNv3TTuKXHouP34TbR/8sw==
+"@wdio/runner@7.29.1":
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.29.1.tgz#9fd2fa6dd28b8b130a10d23452eb155e1e887576"
+ integrity sha512-lJEk/HJ5IiuvAJws8zTx9XL5LJuoexvjWIZmOmFJ6Gv8qRpUx6b0n+JM7vhhbTeIqs4QLXOwTQUHlDDRldQlzQ==
dependencies:
"@wdio/config" "7.26.0"
"@wdio/logger" "7.26.0"
"@wdio/utils" "7.26.0"
deepmerge "^4.0.0"
gaze "^1.1.2"
- webdriver "7.26.0"
- webdriverio "7.26.0"
+ webdriver "7.27.0"
+ webdriverio "7.29.1"
+
+"@wdio/shared-store-service@^7.25.2":
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/shared-store-service/-/shared-store-service-7.29.1.tgz#c43a3dbc7d47c8334970bc173e963688977e8a79"
+ integrity sha512-13VOxyz956DSs2wloQ8gtyEx42zjAuOg+N8/4tGk1p2igPzHB2qUiY/P0yi6zamxYGb6PKLIumIeUjitWHtyWA==
+ dependencies:
+ "@polka/parse" "^1.0.0-next.0"
+ "@wdio/logger" "7.26.0"
+ "@wdio/types" "7.26.0"
+ got "^11.0.2"
+ polka "^0.5.2"
+ webdriverio "7.29.1"
"@wdio/spec-reporter@^7.25.1":
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.26.0.tgz#13eaa5a0fd089684d4c1bcd8ac11dc8646afb5b7"
- integrity sha512-oisyVWn+MRoq0We0qORoDHNk+iKr7CFG4+IE5GCRecR8cgP7dUjVXZcEbn6blgRpry4jOxsAl24frfaPDOsZVA==
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.29.1.tgz#08e13c02ea0876672226d5a2c326dda7e1a66c8e"
+ integrity sha512-bwSGM72QrDedqacY7Wq9Gn86VgRwIGPYzZtcaD7aDnvppCuV8Z/31Wpdfen+CzUk2+whXjXKe66ohPyl9TG5+w==
dependencies:
"@types/easy-table" "^1.2.0"
- "@wdio/reporter" "7.26.0"
+ "@wdio/reporter" "7.29.1"
"@wdio/types" "7.26.0"
chalk "^4.0.0"
easy-table "^1.1.1"
pretty-ms "^7.0.0"
+"@wdio/types@7.25.4":
+ version "7.25.4"
+ resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.25.4.tgz#6f8f028e3108dc880de5068264695f1572e65352"
+ integrity sha512-muvNmq48QZCvocctnbe0URq2FjJjUPIG4iLoeMmyF0AQgdbjaUkMkw3BHYNHVTbSOU9WMsr2z8alhj/I2H6NRQ==
+ dependencies:
+ "@types/node" "^18.0.0"
+ got "^11.8.1"
+
"@wdio/types@7.26.0":
version "7.26.0"
resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.26.0.tgz#70bc879c5dbe316a0eebbac4a46f0f66430b1d84"
resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88"
integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg==
+addressparser@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746"
+ integrity sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==
+
adjust-sourcemap-loader@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz#fc4a0fd080f7d10471f30a7320f25560ade28c99"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
anymatch@~3.1.2:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
- integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3"
integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==
-async@^3.2.3:
+async@^3.1.0, async@^3.2.3:
version "3.2.4"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
+base32.js@0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.1.0.tgz#b582dec693c2f11e893cf064ee6ac5b6131a2202"
+ integrity sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==
+
base64-js@^1.2.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
readable-stream "^3.6.0"
ci-info@^3.2.0:
- version "3.6.1"
- resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.6.1.tgz#7594f1c95cb7fdfddee7af95a13af7dbc67afdcf"
- integrity sha512-up5ggbaDqOqJ4UqLKZ2naVkyqSJQgJi5lwD6b6mM748ysrghDBX0bx/qJTUHzw7zu6Mq4gycviSF5hJnwceD8w==
+ version "3.7.1"
+ resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.7.1.tgz#708a6cdae38915d597afdf3b145f2f8e1ff55f3f"
+ integrity sha512-4jYS4MOAaCIStSRwiuxc4B8MYhIe676yO1sYGzARnjXkWpmzZMMYxY6zu8WYWDhSuth5zhrQ1rhNSibyyvv4/w==
clean-css@5.2.0:
version "5.2.0"
mimic-response "^3.1.0"
deep-equal@^2.0.5:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.1.0.tgz#5ba60402cf44ab92c2c07f3f3312c3d857a0e1dd"
- integrity sha512-2pxgvWu3Alv1PoWEyVg7HS8YhGlUFUV7N5oOvfL6d+7xAmLSemMwv/c8Zv/i9KFzxV5Kt5CAvQc70fLwVuf4UA==
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6"
+ integrity sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==
dependencies:
call-bind "^1.0.2"
es-get-iterator "^1.1.2"
get-intrinsic "^1.1.3"
is-arguments "^1.1.1"
+ is-array-buffer "^3.0.1"
is-date-object "^1.0.5"
is-regex "^1.1.4"
+ is-shared-array-buffer "^1.0.2"
isarray "^2.0.5"
object-is "^1.1.5"
object-keys "^1.1.1"
side-channel "^1.0.4"
which-boxed-primitive "^1.0.2"
which-collection "^1.0.1"
- which-typed-array "^1.1.8"
+ which-typed-array "^1.1.9"
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
-devtools-protocol@^0.0.1069585:
- version "0.0.1069585"
- resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1069585.tgz#c9a9f330462aabf054d581f254b13774297b84f2"
- integrity sha512-sHmkZB6immWQWU4Wx3ogXwxjQUvQc92MmUDL52+q1z2hQmvpOcvDmbsjwX7QZOPTA32dMV7fgT6zUytcpPzy4A==
+devtools-protocol@^0.0.1085790:
+ version "0.0.1085790"
+ resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1085790.tgz#315e4700eb960cf111cc908b9be2caca2257cb13"
+ integrity sha512-f5kfwdOTxPqX5v8ZfAAl9xBgoEVazBYtIONDWIRqYbb7yjOIcnk6vpzCgBCQvav5AuBRLzyUGG0V74OAx93LoA==
-devtools@7.26.0:
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.26.0.tgz#3d568aea2238d190ad0cd71c00483c07c707124a"
- integrity sha512-+8HNbNpzgo4Sn+WcrvXuwsHW9XPJfLo4bs9lgs6DPJHIIDXYJXQGsd7940wMX0Rp0D2vHXA4ibK0oTI5rogM3Q==
+devtools@7.28.1:
+ version "7.28.1"
+ resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.28.1.tgz#9699e0ca41c9a3adfa351d8afac2928f8e1d381c"
+ integrity sha512-sDoszzrXDMLiBQqsg9A5gDqDBwhH4sjYzJIW15lQinB8qgNs0y4o1zdfNlqiKs4HstCA2uFixQeibbDCyMa7hQ==
dependencies:
"@types/node" "^18.0.0"
"@types/ua-parser-js" "^0.7.33"
"@wdio/config" "7.26.0"
"@wdio/logger" "7.26.0"
- "@wdio/protocols" "7.22.0"
+ "@wdio/protocols" "7.27.0"
"@wdio/types" "7.26.0"
"@wdio/utils" "7.26.0"
chrome-launcher "^0.15.0"
unbox-primitive "^1.0.2"
es-get-iterator@^1.1.2:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.2.tgz#9234c54aba713486d7ebde0220864af5e2b283f7"
- integrity sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
+ integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==
dependencies:
call-bind "^1.0.2"
- get-intrinsic "^1.1.0"
- has-symbols "^1.0.1"
- is-arguments "^1.1.0"
+ get-intrinsic "^1.1.3"
+ has-symbols "^1.0.3"
+ is-arguments "^1.1.1"
is-map "^2.0.2"
is-set "^2.0.2"
- is-string "^1.0.5"
+ is-string "^1.0.7"
isarray "^2.0.5"
+ stop-iteration-iterator "^1.0.0"
es-module-lexer@^0.9.0:
version "0.9.3"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5, escape-string-regexp@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
utils-merge "1.0.1"
vary "~1.1.2"
-extend@~3.0.2:
+extend@~3.0.0, extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
dependencies:
assert-plus "^1.0.0"
+git-repo-info@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-2.1.1.tgz#220ffed8cbae74ef8a80e3052f2ccb5179aed058"
+ integrity sha512-8aCohiDo4jwjOwma4FmYFd3i97urZulL8XL24nIPxuE+GZnfsAyy/g2Shqx6OjUiFKUXZM+Yy+KHnOmmA3FVcg==
+
+gitconfiglocal@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-2.1.0.tgz#07c28685c55cc5338b27b5acbcfe34aeb92e43d1"
+ integrity sha512-qoerOEliJn3z+Zyn1HW2F6eoYJqKwS6MgC9cztTLUB/xLWX8gD/6T60pKn4+t/d6tP7JlybI7Z3z+I572CR/Vg==
+ dependencies:
+ ini "^1.3.2"
+
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
once "^1.3.0"
path-is-absolute "^1.0.0"
-glob@8.0.3, glob@^8.0.1, glob@^8.0.3:
+glob@8.0.3, glob@^8.0.1:
version "8.0.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==
once "^1.3.0"
path-is-absolute "^1.0.0"
+glob@^8.0.3:
+ version "8.1.0"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e"
+ integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^5.0.1"
+ once "^1.3.0"
+
glob@~7.1.1:
version "7.1.7"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
dependencies:
get-intrinsic "^1.1.3"
-got@11.8.5, got@^11.0.2, got@^11.8.1:
+got@11.8.5:
version "11.8.5"
resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046"
integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==
p-cancelable "^2.0.0"
responselike "^2.0.0"
+got@^11.0.2, got@^11.8.1:
+ version "11.8.6"
+ resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
+ integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==
+ dependencies:
+ "@sindresorhus/is" "^4.0.0"
+ "@szmarczak/http-timer" "^4.0.5"
+ "@types/cacheable-request" "^6.0.1"
+ "@types/responselike" "^1.0.0"
+ cacheable-lookup "^5.0.3"
+ cacheable-request "^7.0.2"
+ decompress-response "^6.0.0"
+ http2-wrapper "^1.0.0-beta.5.2"
+ lowercase-keys "^2.0.0"
+ p-cancelable "^2.0.0"
+ responselike "^2.0.0"
+
graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
version "4.2.10"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
dependencies:
get-intrinsic "^1.1.1"
-has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3:
+has-symbols@^1.0.2, has-symbols@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
dependencies:
ms "^2.0.0"
-iconv-lite@0.4.24, iconv-lite@^0.4.24:
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1"
integrity sha512-TxYQaeNW/N8ymDvwAxPyRbhMBtnEwuvaTYpOQkFx1nSeusgezHniEc/l35Vo4iCq/mMiTJbpD7oYxN98hFlfmw==
-ini@^1.3.5:
+ini@^1.3.2, ini@^1.3.5:
version "1.3.8"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
has "^1.0.3"
side-channel "^1.0.4"
+internal-slot@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.4.tgz#8551e7baf74a7a6ba5f749cfb16aa60722f0d6f3"
+ integrity sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==
+ dependencies:
+ get-intrinsic "^1.1.3"
+ has "^1.0.3"
+ side-channel "^1.0.4"
+
interpret@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
-is-arguments@^1.1.0, is-arguments@^1.1.1:
+ipv6-normalize@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz#1b3258290d365fa83239e89907dde4592e7620a8"
+ integrity sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==
+
+is-arguments@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==
call-bind "^1.0.2"
has-tostringtag "^1.0.0"
+is-array-buffer@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.1.tgz#deb1db4fcae48308d54ef2442706c0393997052a"
+ integrity sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==
+ dependencies:
+ call-bind "^1.0.2"
+ get-intrinsic "^1.1.3"
+ is-typed-array "^1.1.10"
+
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
bep53-range "^1.1.0"
thirty-two "^1.0.2"
+mailparser-mit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/mailparser-mit/-/mailparser-mit-1.0.0.tgz#19df8436c2a02e1d34a03ec518a2eb065e0a94a4"
+ integrity sha512-sckRITNb3VCT1sQ275g47MAN786pQ5lU20bLY5f794dF/ARGzuVATQ64gO13FOw8jayjFT10e5ttsripKGGXcw==
+ dependencies:
+ addressparser "^1.0.1"
+ iconv-lite "~0.4.24"
+ mime "^1.6.0"
+ uue "^3.1.0"
+
make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+matchit@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/matchit/-/matchit-1.1.0.tgz#c4ccf17d9c824cc1301edbcffde9b75a61d10a7c"
+ integrity sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==
+ dependencies:
+ "@arr/every" "^1.0.0"
+
mathml-tag-names@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
dependencies:
mime-db "1.52.0"
-mime@1.6.0, mime@^1.4.1:
+mime@1.6.0, mime@^1.4.1, mime@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
dependencies:
brace-expansion "^2.0.1"
-minimatch@5.1.0, minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0:
+minimatch@5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7"
integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==
dependencies:
brace-expansion "^1.1.7"
+minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0:
+ version "5.1.6"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
+ integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
+ dependencies:
+ brace-expansion "^2.0.1"
+
minimatch@~3.0.2:
version "3.0.8"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mocha@^10.0.0:
- version "10.1.0"
- resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.1.0.tgz#dbf1114b7c3f9d0ca5de3133906aea3dfc89ef7a"
- integrity sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg==
+ version "10.2.0"
+ resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8"
+ integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==
dependencies:
ansi-colors "4.1.1"
browser-stdout "1.3.1"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
+nodemailer@6.7.3:
+ version "6.7.3"
+ resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018"
+ integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==
+
nopt@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-object-inspect@^1.10.3, object-inspect@^1.12.2, object-inspect@^1.9.0:
+object-inspect@^1.10.3, object-inspect@^1.9.0:
+ version "1.12.3"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
+ integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
+
+object-inspect@^1.12.2:
version "1.12.2"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
+polka@^0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/polka/-/polka-0.5.2.tgz#588bee0c5806dbc6c64958de3a1251860e9f2e26"
+ integrity sha512-FVg3vDmCqP80tOrs+OeNlgXYmFppTXdjD5E7I4ET1NjvtNmQrb1/mJibybKkb/d4NA7YWAr1ojxuhpL3FHqdlw==
+ dependencies:
+ "@polka/url" "^0.5.0"
+ trouter "^2.0.1"
+
postcss-attribute-case-insensitive@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741"
integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
query-selector-shadow-dom@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.0.tgz#8fa7459a4620f094457640e74e953a9dbe61a38e"
- integrity sha512-bK0/0cCI+R8ZmOF1QjT7HupDUYCxbf/9TJgAmSXQxZpftXmTAeil9DRoCnTDkWbvOyZzhcMBwKpptWcdkGFIMg==
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349"
+ integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==
querystring@0.2.0:
version "0.2.0"
lowercase-keys "^2.0.0"
resq@^1.9.1:
- version "1.10.2"
- resolved "https://registry.yarnpkg.com/resq/-/resq-1.10.2.tgz#cedf4f20d53f6e574b1e12afbda446ad9576c193"
- integrity sha512-HmgVS3j+FLrEDBTDYysPdPVF9/hioDMJ/otOiQDKqk77YfZeeLOj0qi34yObumcud1gBpk+wpBTEg4kMicD++A==
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/resq/-/resq-1.11.0.tgz#edec8c58be9af800fd628118c0ca8815283de196"
+ integrity sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==
dependencies:
fast-deep-equal "^2.0.1"
dependencies:
tslib "^1.9.0"
-rxjs@^7.3.0, rxjs@^7.4.0, rxjs@^7.5.5:
+rxjs@^7.3.0, rxjs@^7.4.0:
version "7.5.7"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39"
integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==
dependencies:
tslib "^2.1.0"
+rxjs@^7.5.5:
+ version "7.8.0"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
+ integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
+ dependencies:
+ tslib "^2.1.0"
+
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
+smtp-server@^3.9.0:
+ version "3.11.0"
+ resolved "https://registry.yarnpkg.com/smtp-server/-/smtp-server-3.11.0.tgz#8820c191124fab37a8f16c8325a7f1fd38092c4f"
+ integrity sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==
+ dependencies:
+ base32.js "0.1.0"
+ ipv6-normalize "1.0.1"
+ nodemailer "6.7.3"
+
socket.io-client@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.4.tgz#d3cde8a06a6250041ba7390f08d2468ccebc5ac9"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+stop-iteration-iterator@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"
+ integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==
+ dependencies:
+ internal-slot "^1.0.4"
+
stream-browserify@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
+trouter@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/trouter/-/trouter-2.0.1.tgz#2726a5f8558e090d24c3a393f09eaab1df232df6"
+ integrity sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==
+ dependencies:
+ matchit "^1.0.0"
+
ts-loader@^9.3.0:
version "9.4.1"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.1.tgz#b6f3d82db0eac5a8295994f8cb5e4940ff6b1060"
timeout-refresh "^1.0.0"
unordered-set "^2.0.1"
+uue@^3.1.0:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/uue/-/uue-3.1.2.tgz#e99368414e87200012eb37de4dbaebaa1c742ad2"
+ integrity sha512-axKLXVqwtdI/czrjG0X8hyV1KLgeWx8F4KvSbvVCnS+RUvsQMGRjx0kfuZDXXqj0LYvVJmx3B9kWlKtEdRrJLg==
+ dependencies:
+ escape-string-regexp "~1.0.5"
+ extend "~3.0.0"
+
uuid@8.3.2, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
split2 "^4.1.0"
tcp-port-used "^1.0.2"
-webdriver@7.26.0:
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.26.0.tgz#cc20640ee9906c0126044449dfe9562b6277d14e"
- integrity sha512-T21T31wq29D/rmpFHcAahhdrvfsfXsLs/LBe2su7wL725ptOEoSssuDXjXMkwjf9MSUIXnTcUIz8oJGbKRUMwQ==
+webdriver@7.27.0:
+ version "7.27.0"
+ resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.27.0.tgz#41d23a6c38bd79ea868f0b9fb9c9e3d4b6e4f8bd"
+ integrity sha512-870uIBnrGJ86g3DdYjM+PHhqdWf6NxysSme1KIs6irWxK+LqcaWKWhN75PldE+04xJB2mVWt1tKn0NBBFTWeMg==
dependencies:
"@types/node" "^18.0.0"
"@wdio/config" "7.26.0"
"@wdio/logger" "7.26.0"
- "@wdio/protocols" "7.22.0"
+ "@wdio/protocols" "7.27.0"
"@wdio/types" "7.26.0"
"@wdio/utils" "7.26.0"
got "^11.0.2"
ky "0.30.0"
lodash.merge "^4.6.1"
-webdriverio@7.26.0:
- version "7.26.0"
- resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.26.0.tgz#d6036d950ef96fb6cc29c6c5c9cfc452fcafa59a"
- integrity sha512-7m9TeP871aYxZYKBI4GDh5aQZLN9Fd/PASu5K/jEIT65J4OBB6g5ZaycGFOmfNHCfjWKjwPXZuKiN1f2mcrcRg==
+webdriverio@7.29.1:
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.29.1.tgz#f71c9de317326cff36d22f6277669477e5340c6f"
+ integrity sha512-2xhoaZvV0tzOgnj8H/B4Yol8LTcIrWdfTdfe01d+ERtdzKCoqimmPNP4vpr2lVRVKL/TW4rfoBTBNvDUaJHe2g==
dependencies:
"@types/aria-query" "^5.0.0"
"@types/node" "^18.0.0"
"@wdio/config" "7.26.0"
"@wdio/logger" "7.26.0"
- "@wdio/protocols" "7.22.0"
+ "@wdio/protocols" "7.27.0"
"@wdio/repl" "7.26.0"
"@wdio/types" "7.26.0"
"@wdio/utils" "7.26.0"
aria-query "^5.0.0"
css-shorthand-properties "^1.1.1"
css-value "^0.0.1"
- devtools "7.26.0"
- devtools-protocol "^0.0.1069585"
+ devtools "7.28.1"
+ devtools-protocol "^0.0.1085790"
fs-extra "^10.0.0"
grapheme-splitter "^1.0.2"
lodash.clonedeep "^4.5.0"
resq "^1.9.1"
rgb2hex "0.2.5"
serialize-error "^8.0.0"
- webdriver "7.26.0"
+ webdriver "7.27.0"
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
-which-typed-array@^1.1.8:
+which-typed-array@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6"
integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==
resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
+wildstring@1.0.9:
+ version "1.0.9"
+ resolved "https://registry.yarnpkg.com/wildstring/-/wildstring-1.0.9.tgz#82a696d5653c7d4ec9ba716859b6b53aba2761c5"
+ integrity sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==
+
word-wrap@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
window: 10 minutes
max: 10
+oauth2:
+ token_lifetime:
+ access_token: '1 day'
+ refresh_token: '2 weeks'
+
# Proxies to trust to get real client IP
# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
signup:
enabled: false
+
limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
+
minimum_age: 16 # Used to configure the signup form
+
+ # Users fill a form to register so moderators can accept/reject the registration
+ requires_approval: true
requires_email_verification: false
+
filters:
cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
whitelist: []
secrets:
peertube: 'my super dev secret'
+rates_limit:
+ signup:
+ window: 5 minutes
+ max: 200
+
database:
hostname: 'localhost'
port: 5432
window: 10 minutes
max: 10
+oauth2:
+ token_lifetime:
+ access_token: '1 day'
+ refresh_token: '2 weeks'
+
# Proxies to trust to get real client IP
# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
signup:
enabled: false
+
limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
+
minimum_age: 16 # Used to configure the signup form
+
+ # Users fill a form to register so moderators can accept/reject the registration
+ requires_approval: true
requires_email_verification: false
+
filters:
cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
whitelist: []
signup:
enabled: true
+ requires_approval: false
requires_email_verification: false
transcoding:
"swagger-cli": "^4.0.2",
"ts-node": "^10.8.1",
"tsc-watch": "^5.0.3",
- "typescript": "^4.0.5"
+ "typescript": "~4.8"
},
"bundlewatch": {
"files": [
'Volume': 'Volume',
'Codecs': 'Codecs',
'Color': 'Color',
+ 'Go back to the live': 'Go back to the live',
'Connection Speed': 'Connection Speed',
'Network Activity': 'Network Activity',
'Total Transfered': 'Total Transfered',
// More keys
Object.assign(serverKeys, {
- Misc: 'Misc',
Unknown: 'Unknown'
})
})
})
-const server = createWebsocketTrackerServer(app)
+const { server, trackerServer } = createWebsocketTrackerServer(app)
// ----------- Run -----------
VideoChannelSyncLatestScheduler.Instance.enable()
VideoViewsBufferScheduler.Instance.enable()
GeoIPUpdateScheduler.Instance.enable()
- OpenTelemetryMetrics.Instance.registerMetrics()
+
+ OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
PluginManager.Instance.init(server)
// Before PeerTubeSocket init
if (redirectIfNotOwned(video.url, res)) return
const handler = async (start: number, count: number) => {
- const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
+ const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
return {
total: result.total,
signup: {
enabled: CONFIG.SIGNUP.ENABLED,
limit: CONFIG.SIGNUP.LIMIT,
+ requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
},
--- /dev/null
+import express from 'express'
+import { HttpStatusCode } from '@shared/models'
+import { CONFIG } from '../../../initializers/config'
+import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
+import { asyncMiddleware, buildRateLimiter } from '../../../middlewares'
+import {
+ registrationVerifyEmailValidator,
+ usersAskSendVerifyEmailValidator,
+ usersVerifyEmailValidator
+} from '../../../middlewares/validators'
+
+const askSendEmailLimiter = buildRateLimiter({
+ windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
+ max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
+})
+
+const emailVerificationRouter = express.Router()
+
+emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
+ askSendEmailLimiter,
+ asyncMiddleware(usersAskSendVerifyEmailValidator),
+ asyncMiddleware(reSendVerifyUserEmail)
+)
+
+emailVerificationRouter.post('/:id/verify-email',
+ asyncMiddleware(usersVerifyEmailValidator),
+ asyncMiddleware(verifyUserEmail)
+)
+
+emailVerificationRouter.post('/registrations/:registrationId/verify-email',
+ asyncMiddleware(registrationVerifyEmailValidator),
+ asyncMiddleware(verifyRegistrationEmail)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ emailVerificationRouter
+}
+
+async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
+ const user = res.locals.user
+ const registration = res.locals.userRegistration
+
+ if (user) await sendVerifyUserEmail(user)
+ else if (registration) await sendVerifyRegistrationEmail(registration)
+
+ return res.status(HttpStatusCode.NO_CONTENT_204).end()
+}
+
+async function verifyUserEmail (req: express.Request, res: express.Response) {
+ const user = res.locals.user
+ user.emailVerified = true
+
+ if (req.body.isPendingEmail === true) {
+ user.email = user.pendingEmail
+ user.pendingEmail = null
+ }
+
+ await user.save()
+
+ return res.status(HttpStatusCode.NO_CONTENT_204).end()
+}
+
+async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
+ const registration = res.locals.userRegistration
+ registration.emailVerified = true
+
+ await registration.save()
+
+ return res.status(HttpStatusCode.NO_CONTENT_204).end()
+}
import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
import { MUserAccountDefault } from '@server/types/models'
import { pick } from '@shared/core-utils'
-import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models'
+import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
import { logger } from '../../../helpers/logger'
import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
-import { CONFIG } from '../../../initializers/config'
import { WEBSERVER } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database'
import { Emailer } from '../../../lib/emailer'
-import { Notifier } from '../../../lib/notifier'
import { Redis } from '../../../lib/redis'
-import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user'
+import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
import {
adminUsersSortValidator,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
- buildRateLimiter,
ensureUserHasRight,
- ensureUserRegistrationAllowed,
- ensureUserRegistrationAllowedForIP,
paginationValidator,
setDefaultPagination,
setDefaultSort,
usersAddValidator,
usersGetValidator,
usersListValidator,
- usersRegisterValidator,
usersRemoveValidator,
usersUpdateValidator
} from '../../../middlewares'
import {
ensureCanModerateUser,
usersAskResetPasswordValidator,
- usersAskSendVerifyEmailValidator,
usersBlockingValidator,
- usersResetPasswordValidator,
- usersVerifyEmailValidator
+ usersResetPasswordValidator
} from '../../../middlewares/validators'
import { UserModel } from '../../../models/user/user'
+import { emailVerificationRouter } from './email-verification'
import { meRouter } from './me'
import { myAbusesRouter } from './my-abuses'
import { myBlocklistRouter } from './my-blocklist'
import { myNotificationsRouter } from './my-notifications'
import { mySubscriptionsRouter } from './my-subscriptions'
import { myVideoPlaylistsRouter } from './my-video-playlists'
+import { registrationsRouter } from './registrations'
import { twoFactorRouter } from './two-factor'
const auditLogger = auditLoggerFactory('users')
-const signupRateLimiter = buildRateLimiter({
- windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
- max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
- skipFailedRequests: true
-})
-
-const askSendEmailLimiter = buildRateLimiter({
- windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
- max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
-})
-
const usersRouter = express.Router()
+usersRouter.use('/', emailVerificationRouter)
+usersRouter.use('/', registrationsRouter)
usersRouter.use('/', twoFactorRouter)
usersRouter.use('/', tokensRouter)
usersRouter.use('/', myNotificationsRouter)
asyncRetryTransactionMiddleware(createUser)
)
-usersRouter.post('/register',
- signupRateLimiter,
- asyncMiddleware(ensureUserRegistrationAllowed),
- ensureUserRegistrationAllowedForIP,
- asyncMiddleware(usersRegisterValidator),
- asyncRetryTransactionMiddleware(registerUser)
-)
-
usersRouter.put('/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
asyncMiddleware(resetUserPassword)
)
-usersRouter.post('/ask-send-verify-email',
- askSendEmailLimiter,
- asyncMiddleware(usersAskSendVerifyEmailValidator),
- asyncMiddleware(reSendVerifyUserEmail)
-)
-
-usersRouter.post('/:id/verify-email',
- asyncMiddleware(usersVerifyEmailValidator),
- asyncMiddleware(verifyUserEmail)
-)
-
// ---------------------------------------------------------------------------
export {
})
}
-async function registerUser (req: express.Request, res: express.Response) {
- const body: UserRegister = req.body
-
- const userToCreate = buildUser({
- ...pick(body, [ 'username', 'password', 'email' ]),
-
- emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
- })
-
- const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
- userToCreate,
- userDisplayName: body.displayName || undefined,
- channelNames: body.channel
- })
-
- auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
- logger.info('User %s with its channel and account registered.', body.username)
-
- if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
- await sendVerifyUserEmail(user)
- }
-
- Notifier.Instance.notifyOnNewUserRegistration(user)
-
- Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
-
- return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
-}
-
async function unblockUser (req: express.Request, res: express.Response) {
const user = res.locals.user
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
-async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
- const user = res.locals.user
-
- await sendVerifyUserEmail(user)
-
- return res.status(HttpStatusCode.NO_CONTENT_204).end()
-}
-
-async function verifyUserEmail (req: express.Request, res: express.Response) {
- const user = res.locals.user
- user.emailVerified = true
-
- if (req.body.isPendingEmail === true) {
- user.email = user.pendingEmail
- user.pendingEmail = null
- }
-
- await user.save()
-
- return res.status(HttpStatusCode.NO_CONTENT_204).end()
-}
-
async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
--- /dev/null
+import express from 'express'
+import { Emailer } from '@server/lib/emailer'
+import { Hooks } from '@server/lib/plugins/hooks'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
+import { pick } from '@shared/core-utils'
+import {
+ HttpStatusCode,
+ UserRegister,
+ UserRegistrationRequest,
+ UserRegistrationState,
+ UserRegistrationUpdateState,
+ UserRight
+} from '@shared/models'
+import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
+import { logger } from '../../../helpers/logger'
+import { CONFIG } from '../../../initializers/config'
+import { Notifier } from '../../../lib/notifier'
+import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
+import {
+ acceptOrRejectRegistrationValidator,
+ asyncMiddleware,
+ asyncRetryTransactionMiddleware,
+ authenticate,
+ buildRateLimiter,
+ ensureUserHasRight,
+ ensureUserRegistrationAllowedFactory,
+ ensureUserRegistrationAllowedForIP,
+ getRegistrationValidator,
+ listRegistrationsValidator,
+ paginationValidator,
+ setDefaultPagination,
+ setDefaultSort,
+ userRegistrationsSortValidator,
+ usersDirectRegistrationValidator,
+ usersRequestRegistrationValidator
+} from '../../../middlewares'
+
+const auditLogger = auditLoggerFactory('users')
+
+const registrationRateLimiter = buildRateLimiter({
+ windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
+ max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
+ skipFailedRequests: true
+})
+
+const registrationsRouter = express.Router()
+
+registrationsRouter.post('/registrations/request',
+ registrationRateLimiter,
+ asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
+ ensureUserRegistrationAllowedForIP,
+ asyncMiddleware(usersRequestRegistrationValidator),
+ asyncRetryTransactionMiddleware(requestRegistration)
+)
+
+registrationsRouter.post('/registrations/:registrationId/accept',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+ asyncMiddleware(acceptOrRejectRegistrationValidator),
+ asyncRetryTransactionMiddleware(acceptRegistration)
+)
+registrationsRouter.post('/registrations/:registrationId/reject',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+ asyncMiddleware(acceptOrRejectRegistrationValidator),
+ asyncRetryTransactionMiddleware(rejectRegistration)
+)
+
+registrationsRouter.delete('/registrations/:registrationId',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+ asyncMiddleware(getRegistrationValidator),
+ asyncRetryTransactionMiddleware(deleteRegistration)
+)
+
+registrationsRouter.get('/registrations',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
+ paginationValidator,
+ userRegistrationsSortValidator,
+ setDefaultSort,
+ setDefaultPagination,
+ listRegistrationsValidator,
+ asyncMiddleware(listRegistrations)
+)
+
+registrationsRouter.post('/register',
+ registrationRateLimiter,
+ asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
+ ensureUserRegistrationAllowedForIP,
+ asyncMiddleware(usersDirectRegistrationValidator),
+ asyncRetryTransactionMiddleware(registerUser)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ registrationsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function requestRegistration (req: express.Request, res: express.Response) {
+ const body: UserRegistrationRequest = req.body
+
+ const registration = new UserRegistrationModel({
+ ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
+
+ accountDisplayName: body.displayName,
+ channelDisplayName: body.channel?.displayName,
+ channelHandle: body.channel?.name,
+
+ state: UserRegistrationState.PENDING,
+
+ emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
+ })
+
+ await registration.save()
+
+ if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
+ await sendVerifyRegistrationEmail(registration)
+ }
+
+ Notifier.Instance.notifyOnNewRegistrationRequest(registration)
+
+ Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
+
+ return res.json(registration.toFormattedJSON())
+}
+
+// ---------------------------------------------------------------------------
+
+async function acceptRegistration (req: express.Request, res: express.Response) {
+ const registration = res.locals.userRegistration
+ const body: UserRegistrationUpdateState = req.body
+
+ const userToCreate = buildUser({
+ username: registration.username,
+ password: registration.password,
+ email: registration.email,
+ emailVerified: registration.emailVerified
+ })
+ // We already encrypted password in registration model
+ userToCreate.skipPasswordEncryption = true
+
+ // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
+
+ const { user } = await createUserAccountAndChannelAndPlaylist({
+ userToCreate,
+ userDisplayName: registration.accountDisplayName,
+ channelNames: registration.channelHandle && registration.channelDisplayName
+ ? {
+ name: registration.channelHandle,
+ displayName: registration.channelDisplayName
+ }
+ : undefined
+ })
+
+ registration.userId = user.id
+ registration.state = UserRegistrationState.ACCEPTED
+ registration.moderationResponse = body.moderationResponse
+
+ await registration.save()
+
+ logger.info('Registration of %s accepted', registration.username)
+
+ if (body.preventEmailDelivery !== true) {
+ Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
+ }
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function rejectRegistration (req: express.Request, res: express.Response) {
+ const registration = res.locals.userRegistration
+ const body: UserRegistrationUpdateState = req.body
+
+ registration.state = UserRegistrationState.REJECTED
+ registration.moderationResponse = body.moderationResponse
+
+ await registration.save()
+
+ if (body.preventEmailDelivery !== true) {
+ Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
+ }
+
+ logger.info('Registration of %s rejected', registration.username)
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+// ---------------------------------------------------------------------------
+
+async function deleteRegistration (req: express.Request, res: express.Response) {
+ const registration = res.locals.userRegistration
+
+ await registration.destroy()
+
+ logger.info('Registration of %s deleted', registration.username)
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+// ---------------------------------------------------------------------------
+
+async function listRegistrations (req: express.Request, res: express.Response) {
+ const resultList = await UserRegistrationModel.listForApi({
+ start: req.query.start,
+ count: req.query.count,
+ sort: req.query.sort,
+ search: req.query.search
+ })
+
+ return res.json({
+ total: resultList.total,
+ data: resultList.data.map(d => d.toFormattedJSON())
+ })
+}
+
+// ---------------------------------------------------------------------------
+
+async function registerUser (req: express.Request, res: express.Response) {
+ const body: UserRegister = req.body
+
+ const userToCreate = buildUser({
+ ...pick(body, [ 'username', 'password', 'email' ]),
+
+ emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
+ })
+
+ const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
+ userToCreate,
+ userDisplayName: body.displayName || undefined,
+ channelNames: body.channel
+ })
+
+ auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
+ logger.info('User %s with its channel and account registered.', body.username)
+
+ if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
+ await sendVerifyUserEmail(user)
+ }
+
+ Notifier.Instance.notifyOnNewDirectRegistration(user)
+
+ Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
import { resetSequelizeInstance } from '../../helpers/database-utils'
-import { buildNSFWFilter, createReqFiles } from '../../helpers/express-utils'
+import { createReqFiles } from '../../helpers/express-utils'
import { logger } from '../../helpers/logger'
import { getFormattedObjects } from '../../helpers/utils'
import { CONFIG } from '../../initializers/config'
'filter:api.video-playlist.videos.list.result'
)
- const options = {
- displayNSFW: buildNSFWFilter(res, req.query.nsfw),
- accountId: user ? user.Account.id : undefined
- }
+ const options = { accountId: user?.Account?.id }
return res.json(getFormattedObjects(resultList.data, resultList.total, options))
}
+import { MCommentFormattable } from '@server/types/models'
import express from 'express'
+
import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
- let resultList: ThreadsResultList<VideoCommentModel>
+ let resultList: ThreadsResultList<MCommentFormattable>
if (video.commentsEnabled === true) {
const apiOptions = await Hooks.wrapObject({
const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
- let resultList: ResultList<VideoCommentModel>
+ let resultList: ResultList<MCommentFormattable>
if (video.commentsEnabled === true) {
const apiOptions = await Hooks.wrapObject({
videoId: video.id,
- isVideoOwned: video.isOwned(),
threadId: res.locals.videoCommentThread.id,
user
}, 'filter:api.video-thread-comments.list.params')
function generateToken (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
- const { token, expires } = VideoTokensManager.Instance.create(video.uuid)
+ const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
return res.json({
files: {
content: toSafeHtml(video.description),
author: [
{
- name: video.VideoChannel.Account.getDisplayName(),
- link: video.VideoChannel.Account.Actor.url
+ name: video.VideoChannel.getDisplayName(),
+ link: video.VideoChannel.Actor.url
}
],
date: video.publishedAt,
import { Server as TrackerServer } from 'bittorrent-tracker'
import express from 'express'
import { createServer } from 'http'
+import LRUCache from 'lru-cache'
import proxyAddr from 'proxy-addr'
import { WebSocketServer } from 'ws'
-import { Redis } from '@server/lib/redis'
import { logger } from '../helpers/logger'
import { CONFIG } from '../initializers/config'
-import { TRACKER_RATE_LIMITS } from '../initializers/constants'
+import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
import { VideoFileModel } from '../models/video/video-file'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
const trackerRouter = express.Router()
+const blockedIPs = new LRUCache<string, boolean>({
+ max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
+ ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
+})
+
let peersIps = {}
let peersIpInfoHash = {}
runPeersChecker()
// Close socket connection and block IP for a few time
if (params.type === 'ws') {
- Redis.Instance.setTrackerBlockIP(ip)
- .catch(err => logger.error('Cannot set tracker block ip.', { err }))
+ blockedIPs.set(ip, true)
// setTimeout to wait filter response
setTimeout(() => params.socket.close(), 0)
if (request.url === '/tracker/socket') {
const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
- Redis.Instance.doesTrackerBlockIPExist(ip)
- .then(result => {
- if (result === true) {
- logger.debug('Blocking IP %s from tracker.', ip)
+ if (blockedIPs.has(ip)) {
+ logger.debug('Blocking IP %s from tracker.', ip)
- socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
- socket.destroy()
- return
- }
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
+ socket.destroy()
+ return
+ }
- // FIXME: typings
- return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
- })
- .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
+ // FIXME: typings
+ return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
}
// Don't destroy socket, we have Socket.IO too
})
- return server
+ return { server, trackerServer }
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
function toCompleteUUID (value: string) {
- if (isShortUUID(value)) return shortToUUID(value)
+ if (isShortUUID(value)) {
+ try {
+ return shortToUUID(value)
+ } catch {
+ return null
+ }
+ }
return value
}
--- /dev/null
+import validator from 'validator'
+import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants'
+import { exists } from './misc'
+
+const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS
+
+function isRegistrationStateValid (value: string) {
+ return exists(value) && USER_REGISTRATION_STATES[value] !== undefined
+}
+
+function isRegistrationModerationResponseValid (value: string) {
+ return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE)
+}
+
+function isRegistrationReasonValid (value: string) {
+ return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ isRegistrationStateValid,
+ isRegistrationModerationResponseValid,
+ isRegistrationReasonValid
+}
return exists(value) && VIDEO_LANGUAGES[value] !== undefined
}
-const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
- .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
- .map(m => `(${m})`)
- .join('|')
+// MacOS sends application/octet-stream
+const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
+ .map(m => `(${m})`)
+ .join('|')
+
function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
return isFileValid({
files,
return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
}
-const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
- .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
- .map(m => `(${m})`)
- .join('|')
+// MacOS sends application/octet-stream
+const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
+ .map(m => `(${m})`)
+ .join('|')
+
function isVideoImportTorrentFile (files: UploadFilesForCheck) {
return isFileValid({
files,
};
function removeCachedPath (pluginPath: string) {
- const pathCache = (module.constructor as any)._pathCache
+ const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
Object.keys(pathCache).forEach(function (cacheKey) {
if (cacheKey.includes(pluginPath)) {
--- /dev/null
+import memoizee from 'memoizee'
+
+export function Memoize (config?: memoizee.Options<any>) {
+ return function (_target, _key, descriptor: PropertyDescriptor) {
+ const oldFunction = descriptor.value
+ const newFunction = memoizee(oldFunction, config)
+
+ descriptor.value = function () {
+ return newFunction.apply(this, arguments)
+ }
+ }
+}
import { logger, loggerTagsFactory } from '../logger'
import { getProxy, isProxyEnabled } from '../proxy'
import { isBinaryResponse, peertubeGot } from '../requests'
+import { OptionsOfBufferResponseBody } from 'got/dist/source'
const lTags = loggerTagsFactory('youtube-dl')
logger.info('Updating youtubeDL binary from %s.', url, lTags())
- const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' }
+ const gotOptions: OptionsOfBufferResponseBody = {
+ context: { bodyKBLimit: 20_000 },
+ responseType: 'buffer' as 'buffer'
+ }
+
+ if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
+ gotOptions.headers = {
+ authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
+ }
+ }
try {
let gotResult = await peertubeGot(url, gotOptions)
import { uniqify } from '@shared/core-utils'
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
-import { isProdInstance, parseSemVersion } from '../helpers/core-utils'
+import { isProdInstance, parseBytes, parseSemVersion } from '../helpers/core-utils'
import { isArray } from '../helpers/custom-validators/misc'
import { logger } from '../helpers/logger'
import { ApplicationModel, getServerActor } from '../models/application/application'
throw new Error('Emailer is disabled but you require signup email verification.')
}
+ if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_APPROVAL) {
+ // eslint-disable-next-line max-len
+ logger.warn('Emailer is disabled but signup approval is enabled: PeerTube will not be able to send an email to the user upon acceptance/rejection of the registration request')
+ }
+
if (CONFIG.CONTACT_FORM.ENABLED) {
logger.warn('Emailer is disabled so the contact form will not work.')
}
function checkStorageConfig () {
// Check storage directory locations
if (isProdInstance()) {
- const configStorage = config.get('storage')
+ const configStorage = config.get<{ [ name: string ]: string }>('storage')
+
for (const key of Object.keys(configStorage)) {
if (configStorage[key].startsWith('storage/')) {
logger.warn(
'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
)
}
+
+ if (CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART > parseBytes('250MB')) {
+ // eslint-disable-next-line max-len
+ logger.warn(`Object storage max upload part seems to have a big value (${CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART} bytes). Consider using a lower one (like 100MB).`)
+ }
}
}
'webserver.https', 'webserver.hostname', 'webserver.port',
'secrets.peertube',
'trust_proxy',
+ 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
'email.body.signature', 'email.subject.prefix',
'csp.enabled', 'csp.report_only', 'csp.report_uri',
'security.frameguard.enabled',
'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled',
- 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.minimum_age',
+ 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'redundancy.videos.strategies', 'redundancy.videos.check_interval',
'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled',
HOSTNAME: config.get<string>('webserver.hostname'),
PORT: config.get<number>('webserver.port')
},
+ OAUTH2: {
+ TOKEN_LIFETIME: {
+ ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
+ REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
+ }
+ },
RATES_LIMIT: {
API: {
WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
},
SIGNUP: {
get ENABLED () { return config.get<boolean>('signup.enabled') },
+ get REQUIRES_APPROVAL () { return config.get<boolean>('signup.requires_approval') },
get LIMIT () { return config.get<number>('signup.limit') },
get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') },
import {
AbuseState,
JobType,
+ UserRegistrationState,
VideoChannelSyncState,
VideoImportState,
VideoPrivacy,
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 745
+const LAST_MIGRATION_VERSION = 755
// ---------------------------------------------------------------------------
ACCOUNT_FOLLOWERS: [ 'createdAt' ],
CHANNEL_FOLLOWERS: [ 'createdAt' ],
+ USER_REGISTRATIONS: [ 'createdAt', 'state' ],
+
VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],
// Don't forget to update peertube-search-index with the same values
VIDEO_REDUNDANCIES: [ 'name' ]
}
-const OAUTH_LIFETIME = {
- ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
- REFRESH_TOKEN: 1209600 // 2 weeks
-}
-
const ROUTE_CACHE_LIFETIME = {
FEEDS: '15 minutes',
ROBOTS: '2 hours',
ABUSE_MESSAGES: {
MESSAGE: { min: 2, max: 3000 } // Length
},
+ USER_REGISTRATIONS: {
+ REASON_MESSAGE: { min: 2, max: 3000 }, // Length
+ MODERATOR_MESSAGE: { min: 2, max: 3000 } // Length
+ },
VIDEO_BLACKLIST: {
REASON: { min: 2, max: 300 } // Length
},
[AbuseState.ACCEPTED]: 'Accepted'
}
+const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = {
+ [UserRegistrationState.PENDING]: 'Pending',
+ [UserRegistrationState.REJECTED]: 'Rejected',
+ [UserRegistrationState.ACCEPTED]: 'Accepted'
+}
+
const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
-const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
+const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
DO_NOT_LIST: 'do_not_list',
VIDEO_TOKENS: {
MAX_SIZE: 100_000,
TTL: parseDurationToMs('8 hours')
+ },
+ TRACKER_IPS: {
+ MAX_SIZE: 100_000
}
}
INTERVAL: 60000 * 5, // 5 minutes
ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
- BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes
+ BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
}
const P2P_MEDIA_LOADER_PEER_VERSION = 2
JOB_ATTEMPTS,
AP_CLEANER,
LAST_MIGRATION_VERSION,
- OAUTH_LIFETIME,
CUSTOM_HTML_TAG_COMMENTS,
STATS_TIMESERIE,
BROADCAST_CONCURRENCY,
VIDEO_TRANSCODING_FPS,
FFMPEG_NICE,
ABUSE_STATES,
+ USER_REGISTRATION_STATES,
LRU_CACHE,
REQUEST_TIMEOUTS,
MAX_LOCAL_VIEWER_WATCH_SECTIONS,
USER_PASSWORD_RESET_LIFETIME,
USER_PASSWORD_CREATE_LIFETIME,
MEMOIZE_TTL,
- USER_EMAIL_VERIFY_LIFETIME,
+ EMAIL_VERIFY_LIFETIME,
OVERVIEWS,
SCHEDULER_INTERVALS_MS,
REPEAT_JOBS,
import { VideoTrackerModel } from '@server/models/server/video-tracker'
import { UserModel } from '@server/models/user/user'
import { UserNotificationModel } from '@server/models/user/user-notification'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
+import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
import { VideoSourceModel } from '@server/models/video/video-source'
import { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/view/video-view'
import { CONFIG } from './config'
-import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
PluginModel,
ActorCustomPageModel,
VideoJobInfoModel,
- VideoChannelSyncModel
+ VideoChannelSyncModel,
+ UserRegistrationModel
])
// Check extensions exist in the database
const tasks: Promise<any>[] = []
// Cache directories
- for (const key of Object.keys(cacheDirectories)) {
- const dir = cacheDirectories[key]
+ for (const dir of cacheDirectories) {
tasks.push(removeDirectoryOrContent(dir))
}
}
// Cache directories
- for (const key of Object.keys(cacheDirectories)) {
- const dir = cacheDirectories[key]
+ for (const dir of cacheDirectories) {
tasks.push(ensureDir(dir))
}
--- /dev/null
+
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+ db: any
+}): Promise<void> {
+ {
+ const query = `
+ CREATE TABLE IF NOT EXISTS "userRegistration" (
+ "id" serial,
+ "state" integer NOT NULL,
+ "registrationReason" text NOT NULL,
+ "moderationResponse" text,
+ "password" varchar(255),
+ "username" varchar(255) NOT NULL,
+ "email" varchar(400) NOT NULL,
+ "emailVerified" boolean,
+ "accountDisplayName" varchar(255),
+ "channelHandle" varchar(255),
+ "channelDisplayName" varchar(255),
+ "userId" integer REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ "createdAt" timestamp with time zone NOT NULL,
+ "updatedAt" timestamp with time zone NOT NULL,
+ PRIMARY KEY ("id")
+ );
+ `
+ await utils.sequelize.query(query, { transaction: utils.transaction })
+ }
+
+ {
+ await utils.queryInterface.addColumn('userNotification', 'userRegistrationId', {
+ type: Sequelize.INTEGER,
+ defaultValue: null,
+ allowNull: true,
+ references: {
+ model: 'userRegistration',
+ key: 'id'
+ },
+ onUpdate: 'CASCADE',
+ onDelete: 'SET NULL'
+ }, { transaction: utils.transaction })
+ }
+}
+
+async function down (utils: {
+ queryInterface: Sequelize.QueryInterface
+ transaction: Sequelize.Transaction
+}) {
+ await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction })
+}
+
+export {
+ up,
+ down
+}
--- /dev/null
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+ db: any
+}): Promise<void> {
+ const { transaction } = utils
+
+ const query = 'DELETE FROM "localVideoViewer" t1 ' +
+ 'USING (SELECT MIN(id) as id, "url" FROM "localVideoViewer" GROUP BY "url" HAVING COUNT(*) > 1) t2 ' +
+ 'WHERE t1."url" = t2."url" AND t1.id <> t2.id'
+
+ await utils.sequelize.query(query, { transaction })
+}
+
+async function down (utils: {
+ queryInterface: Sequelize.QueryInterface
+ transaction: Sequelize.Transaction
+}) {
+}
+
+export {
+ up,
+ down
+}
-import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users'
+import {
+ isUserAdminFlagsValid,
+ isUserDisplayNameValid,
+ isUserRoleValid,
+ isUserUsernameValid,
+ isUserVideoQuotaDailyValid,
+ isUserVideoQuotaValid
+} from '@server/helpers/custom-validators/users'
import { logger } from '@server/helpers/logger'
import { generateRandomString } from '@server/helpers/utils'
import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
import { PluginManager } from '@server/lib/plugins/plugin-manager'
import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
+import { MUser } from '@server/types/models'
import {
RegisterServerAuthenticatedResult,
RegisterServerAuthPassOptions,
RegisterServerExternalAuthenticatedResult
} from '@server/types/plugins/register-server-auth.model'
-import { UserRole } from '@shared/models'
+import { UserAdminFlag, UserRole } from '@shared/models'
+import { BypassLogin } from './oauth-model'
+
+export type ExternalUser =
+ Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
+ { displayName: string }
// Token is the key, expiration date is the value
const authBypassTokens = new Map<string, {
expires: Date
- user: {
- username: string
- email: string
- displayName: string
- role: UserRole
- }
+ user: ExternalUser
+ userUpdater: RegisterServerAuthenticatedResult['userUpdater']
authName: string
npmName: string
}>()
expires,
user,
npmName,
- authName
+ authName,
+ userUpdater: authResult.userUpdater
})
// Cleanup expired tokens
return tokenModel?.authName
}
-async function getBypassFromPasswordGrant (username: string, password: string) {
+async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
const plugins = PluginManager.Instance.getIdAndPassAuths()
const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
bypass: true,
pluginName: pluginAuth.npmName,
authName: authOptions.authName,
- user: buildUserResult(loginResult)
+ user: buildUserResult(loginResult),
+ userUpdater: loginResult.userUpdater
}
} catch (err) {
logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
return undefined
}
-function getBypassFromExternalAuth (username: string, externalAuthToken: string) {
+function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
const obj = authBypassTokens.get(externalAuthToken)
if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
bypass: true,
pluginName: npmName,
authName,
+ userUpdater: obj.userUpdater,
user
}
}
function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
- if (!isUserUsernameValid(result.username)) {
- logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username })
+ const returnError = (field: string) => {
+ logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
return false
}
- if (!result.email) {
- logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email })
- return false
- }
+ if (!isUserUsernameValid(result.username)) return returnError('username')
+ if (!result.email) return returnError('email')
- // role is optional
- if (result.role && !isUserRoleValid(result.role)) {
- logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role })
- return false
- }
+ // Following fields are optional
+ if (result.role && !isUserRoleValid(result.role)) return returnError('role')
+ if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
+ if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
+ if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
+ if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
- // display name is optional
- if (result.displayName && !isUserDisplayNameValid(result.displayName)) {
- logger.error(
- 'Auth method %s of plugin %s did not provide a valid display name.',
- authName, npmName, { displayName: result.displayName }
- )
+ if (result.userUpdater && typeof result.userUpdater !== 'function') {
+ logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
return false
}
username: pluginResult.username,
email: pluginResult.email,
role: pluginResult.role ?? UserRole.USER,
- displayName: pluginResult.displayName || pluginResult.username
+ displayName: pluginResult.displayName || pluginResult.username,
+
+ adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
+
+ videoQuota: pluginResult.videoQuota,
+ videoQuotaDaily: pluginResult.videoQuotaDaily
}
}
import express from 'express'
import { AccessDeniedError } from '@node-oauth/oauth2-server'
import { PluginManager } from '@server/lib/plugins/plugin-manager'
+import { AccountModel } from '@server/models/account/account'
+import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
import { MOAuthClient } from '@server/types/models'
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
-import { MUser } from '@server/types/models/user/user'
+import { MUser, MUserDefault } from '@server/types/models/user/user'
import { pick } from '@shared/core-utils'
-import { UserRole } from '@shared/models/users/user-role'
+import { AttributesOnly } from '@shared/typescript-utils'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { OAuthClientModel } from '../../models/oauth/oauth-client'
import { UserModel } from '../../models/user/user'
import { findAvailableLocalActorName } from '../local-actor'
import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
+import { ExternalUser } from './external-auth'
import { TokensCache } from './tokens-cache'
type TokenInfo = {
bypass: boolean
pluginName: string
authName?: string
- user: {
- username: string
- email: string
- displayName: string
- role: UserRole
- }
+ user: ExternalUser
+ userUpdater: RegisterServerAuthenticatedResult['userUpdater']
}
async function getAccessToken (bearerToken: string) {
logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
let user = await UserModel.loadByEmail(bypassLogin.user.email)
+
if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
+ else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
// Cannot create a user
if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
// ---------------------------------------------------------------------------
-async function createUserFromExternal (pluginAuth: string, options: {
- username: string
- email: string
- role: UserRole
- displayName: string
-}) {
- const username = await findAvailableLocalActorName(options.username)
+async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
+ const username = await findAvailableLocalActorName(userOptions.username)
const userToCreate = buildUser({
- ...pick(options, [ 'email', 'role' ]),
+ ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
username,
emailVerified: null,
const { user } = await createUserAccountAndChannelAndPlaylist({
userToCreate,
- userDisplayName: options.displayName
+ userDisplayName: userOptions.displayName
})
return user
}
+async function updateUserFromExternal (
+ user: MUserDefault,
+ userOptions: ExternalUser,
+ userUpdater: RegisterServerAuthenticatedResult['userUpdater']
+) {
+ if (!userUpdater) return user
+
+ {
+ type UserAttributeKeys = keyof AttributesOnly<UserModel>
+ const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
+ role: 'role',
+ adminFlags: 'adminFlags',
+ videoQuota: 'videoQuota',
+ videoQuotaDaily: 'videoQuotaDaily'
+ }
+
+ for (const modelKey of Object.keys(mappingKeys)) {
+ const pluginOptionKey = mappingKeys[modelKey]
+
+ const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
+ user.set(modelKey, newValue)
+ }
+ }
+
+ {
+ type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
+ const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
+ name: 'displayName'
+ }
+
+ for (const modelKey of Object.keys(mappingKeys)) {
+ const optionKey = mappingKeys[modelKey]
+
+ const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
+ user.Account.set(modelKey, newValue)
+ }
+ }
+
+ logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
+
+ user.Account = await user.Account.save()
+
+ return user.save()
+}
+
function checkUserValidityOrThrow (user: MUser) {
if (user.blocked) throw new AccessDeniedError('User is blocked.')
}
} from '@node-oauth/oauth2-server'
import { randomBytesPromise } from '@server/helpers/core-utils'
import { isOTPValid } from '@server/helpers/otp'
+import { CONFIG } from '@server/initializers/config'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
import { MOAuthClient } from '@server/types/models'
import { sha1 } from '@shared/extra-utils'
-import { HttpStatusCode } from '@shared/models'
-import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
+import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models'
+import { OTP } from '../../initializers/constants'
import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
class MissingTwoFactorError extends Error {
code = HttpStatusCode.UNAUTHORIZED_401
- name = 'missing_two_factor'
+ name = ServerErrorCode.MISSING_TWO_FACTOR
}
class InvalidTwoFactorError extends Error {
code = HttpStatusCode.BAD_REQUEST_400
- name = 'invalid_two_factor'
+ name = ServerErrorCode.INVALID_TWO_FACTOR
+}
+
+class RegistrationWaitingForApproval extends Error {
+ code = HttpStatusCode.BAD_REQUEST_400
+ name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
+}
+
+class RegistrationApprovalRejected extends Error {
+ code = HttpStatusCode.BAD_REQUEST_400
+ name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
}
/**
*
*/
const oAuthServer = new OAuth2Server({
- accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN,
- refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN,
+ // Wants seconds
+ accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
+ refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
// See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
model: require('./oauth-model')
}
const user = await getUser(request.body.username, request.body.password, bypassLogin)
- if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
+ if (!user) {
+ const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
+
+ if (registration?.state === UserRegistrationState.REJECTED) {
+ throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
+ } else if (registration?.state === UserRegistrationState.PENDING) {
+ throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
+ }
+
+ throw new InvalidGrantError('Invalid grant: user credentials are invalid')
+ }
if (user.otpSecret) {
if (!request.headers[OTP.HEADER_NAME]) {
function getTokenExpiresAt (type: 'access' | 'refresh') {
const lifetime = type === 'access'
- ? OAUTH_LIFETIME.ACCESS_TOKEN
- : OAUTH_LIFETIME.REFRESH_TOKEN
+ ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
+ : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
- return new Date(Date.now() + lifetime * 1000)
+ return new Date(Date.now() + lifetime)
}
async function buildToken () {
const token = this.userHavingToken.get(userId)
if (token !== undefined) {
- this.accessTokenCache.del(token)
- this.userHavingToken.del(userId)
+ this.accessTokenCache.delete(token)
+ this.userHavingToken.delete(userId)
}
}
const tokenModel = this.accessTokenCache.get(token)
if (tokenModel !== undefined) {
- this.userHavingToken.del(tokenModel.userId)
- this.accessTokenCache.del(token)
+ this.userHavingToken.delete(tokenModel.userId)
+ this.accessTokenCache.delete(token)
}
}
}
import { createTransport, Transporter } from 'nodemailer'
import { join } from 'path'
import { arrayify, root } from '@shared/core-utils'
-import { EmailPayload } from '@shared/models'
+import { EmailPayload, UserRegistrationState } from '@shared/models'
import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
import { isTestOrDevInstance } from '../helpers/core-utils'
import { bunyanLogger, logger } from '../helpers/logger'
import { CONFIG, isEmailEnabled } from '../initializers/config'
import { WEBSERVER } from '../initializers/constants'
-import { MUser } from '../types/models'
+import { MRegistration, MUser } from '../types/models'
import { JobQueue } from './job-queue'
const Email = require('email-templates')
subject: 'Reset your account password',
locals: {
username,
- resetPasswordUrl
+ resetPasswordUrl,
+
+ hideNotificationPreferencesLink: true
}
}
subject: 'Create your account password',
locals: {
username,
- createPasswordUrl
+ createPasswordUrl,
+
+ hideNotificationPreferencesLink: true
}
}
return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
}
- addVerifyEmailJob (username: string, to: string, verifyEmailUrl: string) {
+ addVerifyEmailJob (options: {
+ username: string
+ isRegistrationRequest: boolean
+ to: string
+ verifyEmailUrl: string
+ }) {
+ const { username, isRegistrationRequest, to, verifyEmailUrl } = options
+
const emailPayload: EmailPayload = {
template: 'verify-email',
to: [ to ],
subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
locals: {
username,
- verifyEmailUrl
+ verifyEmailUrl,
+ isRegistrationRequest,
+
+ hideNotificationPreferencesLink: true
}
}
body,
// There are not notification preferences for the contact form
- hideNotificationPreferences: true
+ hideNotificationPreferencesLink: true
+ }
+ }
+
+ return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
+ }
+
+ addUserRegistrationRequestProcessedJob (registration: MRegistration) {
+ let template: string
+ let subject: string
+ if (registration.state === UserRegistrationState.ACCEPTED) {
+ template = 'user-registration-request-accepted'
+ subject = `Your registration request for ${registration.username} has been accepted`
+ } else {
+ template = 'user-registration-request-rejected'
+ subject = `Your registration request for ${registration.username} has been rejected`
+ }
+
+ const to = registration.email
+ const emailPayload: EmailPayload = {
+ to: [ to ],
+ template,
+ subject,
+ locals: {
+ username: registration.username,
+ moderationResponse: registration.moderationResponse,
+ loginLink: WEBSERVER.URL + '/login'
}
}
td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
br
//- Clear Spacer : END
- //- 1 Column Text : BEGIN
- if username
- tr
- td(style='background-color: #cccccc;')
- table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
- tr
- td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
- p(style='margin: 0;')
- | You are receiving this email as part of your notification settings on #{instanceName} for your account #{username}.
- //- 1 Column Text : END
//- Email Body : END
//- Email Footer : BEGIN
- unless hideNotificationPreferences
+ unless hideNotificationPreferencesLink
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
tr
td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
--- /dev/null
+extends ../common/greetings
+
+block title
+ | Congratulation #{username}, your registration request has been accepted!
+
+block content
+ p Your registration request has been accepted.
+ p Moderators sent you the following message:
+ blockquote(style='white-space: pre-wrap') #{moderationResponse}
+ p Your account has been created and you can login on #[a(href=loginLink) #{loginLink}]
--- /dev/null
+extends ../common/greetings
+
+block title
+ | Registration request of your account #{username} has rejected
+
+block content
+ p Your registration request has been rejected.
+ p Moderators sent you the following message:
+ blockquote(style='white-space: pre-wrap') #{moderationResponse}
--- /dev/null
+extends ../common/greetings
+
+block title
+ | A new user wants to register
+
+block content
+ p User #{registration.username} wants to register on your PeerTube instance with the following reason:
+ blockquote(style='white-space: pre-wrap') #{registration.registrationReason}
+ p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration].
extends ../common/greetings
block title
- | Account verification
+ | Email verification
block content
- p Welcome to #{instanceName}!
- p.
- You just created an account at #[a(href=WEBSERVER.URL) #{instanceName}].
- Your username there is: #{username}.
- p.
- To start using your account you must verify your email first!
- Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
- p.
- If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
- p.
- If you are not the person who initiated this request, please ignore this email.
+ if isRegistrationRequest
+ p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
+ else
+ p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
+
+ if isRegistrationRequest
+ p To complete your registration request you must verify your email first!
+ else
+ p To start using your account you must verify your email first!
+
+ p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
+ p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
+ p If you are not the person who initiated this request, please ignore this email.
this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
- for (const handlerName of (Object.keys(handlers) as JobType[])) {
+ for (const handlerName of Object.keys(handlers)) {
this.buildWorker(handlerName)
this.buildQueue(handlerName)
this.buildQueueScheduler(handlerName)
-import { MUser, MUserDefault } from '@server/types/models/user'
+import { MRegistration, MUser, MUserDefault } from '@server/types/models/user'
import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
import { UserNotificationSettingValue } from '../../../shared/models/users'
import { logger } from '../../helpers/logger'
AbuseStateChangeForReporter,
AutoFollowForInstance,
CommentMention,
+ DirectRegistrationForModerators,
FollowForInstance,
FollowForUser,
ImportFinishedForOwner,
OwnedPublicationAfterAutoUnblacklist,
OwnedPublicationAfterScheduleUpdate,
OwnedPublicationAfterTranscoding,
- RegistrationForModerators,
+ RegistrationRequestForModerators,
StudioEditionFinishedForOwner,
UnblacklistForOwner
} from './shared'
newBlacklist: [ NewBlacklistForOwner ],
unblacklist: [ UnblacklistForOwner ],
importFinished: [ ImportFinishedForOwner ],
- userRegistration: [ RegistrationForModerators ],
+ directRegistration: [ DirectRegistrationForModerators ],
+ registrationRequest: [ RegistrationRequestForModerators ],
userFollow: [ FollowForUser ],
instanceFollow: [ FollowForInstance ],
autoInstanceFollow: [ AutoFollowForInstance ],
})
}
- notifyOnNewUserRegistration (user: MUserDefault): void {
- const models = this.notificationModels.userRegistration
+ notifyOnNewDirectRegistration (user: MUserDefault): void {
+ const models = this.notificationModels.directRegistration
this.sendNotifications(models, user)
.catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
}
+ notifyOnNewRegistrationRequest (registration: MRegistration): void {
+ const models = this.notificationModels.registrationRequest
+
+ this.sendNotifications(models, registration)
+ .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err }))
+ }
+
notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
const models = this.notificationModels.userFollow
import { UserNotificationType, UserRight } from '@shared/models'
import { AbstractNotification } from '../common/abstract-notification'
-export class RegistrationForModerators extends AbstractNotification <MUserDefault> {
+export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
private moderators: MUserDefault[]
async prepare () {
return {
template: 'user-registered',
to,
- subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
+ subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
locals: {
user: this.payload
}
export * from './new-peertube-version-for-admins'
export * from './new-plugin-version-for-admins'
-export * from './registration-for-moderators'
+export * from './direct-registration-for-moderators'
+export * from './registration-request-for-moderators'
--- /dev/null
+import { logger } from '@server/helpers/logger'
+import { UserModel } from '@server/models/user/user'
+import { UserNotificationModel } from '@server/models/user/user-notification'
+import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
+import { UserNotificationType, UserRight } from '@shared/models'
+import { AbstractNotification } from '../common/abstract-notification'
+
+export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
+ private moderators: MUserDefault[]
+
+ async prepare () {
+ this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS)
+ }
+
+ log () {
+ logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username)
+ }
+
+ getSetting (user: MUserWithNotificationSetting) {
+ return user.NotificationSetting.newUserRegistration
+ }
+
+ getTargetUsers () {
+ return this.moderators
+ }
+
+ createNotification (user: MUserWithNotificationSetting) {
+ const notification = UserNotificationModel.build<UserNotificationModelForApi>({
+ type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST,
+ userId: user.id,
+ userRegistrationId: this.payload.id
+ })
+ notification.UserRegistration = this.payload
+
+ return notification
+ }
+
+ createEmail (to: string) {
+ return {
+ template: 'user-registration-request',
+ to,
+ subject: `A new user wants to register: ${this.payload.username}`,
+ locals: {
+ registration: this.payload
+ }
+ }
+ }
+}
--- /dev/null
+import { Meter } from '@opentelemetry/api'
+
+export class BittorrentTrackerObserversBuilder {
+
+ constructor (private readonly meter: Meter, private readonly trackerServer: any) {
+
+ }
+
+ buildObservers () {
+ const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
+ description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
+ })
+ const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
+ description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
+ })
+ const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
+ description: 'Total peers in the PeerTube BitTorrent Tracker'
+ })
+
+ this.meter.addBatchObservableCallback(observableResult => {
+ const infohashes = Object.keys(this.trackerServer.torrents)
+
+ const counters = {
+ activeInfohashes: 0,
+ inactiveInfohashes: 0,
+ peers: 0,
+ uncompletedPeers: 0
+ }
+
+ for (const infohash of infohashes) {
+ const content = this.trackerServer.torrents[infohash]
+
+ const peers = content.peers
+ if (peers.keys.length !== 0) counters.activeInfohashes++
+ else counters.inactiveInfohashes++
+
+ for (const peerId of peers.keys) {
+ const peer = peers.peek(peerId)
+ if (peer == null) return
+
+ counters.peers++
+ }
+ }
+
+ observableResult.observe(activeInfohashes, counters.activeInfohashes)
+ observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
+ observableResult.observe(peers, counters.peers)
+ }, [ activeInfohashes, inactiveInfohashes, peers ])
+ }
+
+}
+export * from './bittorrent-tracker-observers-builder'
export * from './lives-observers-builder'
export * from './job-queue-observers-builder'
export * from './nodejs-observers-builder'
import { MVideoImmutable } from '@server/types/models'
import { PlaybackMetricCreate } from '@shared/models'
import {
+ BittorrentTrackerObserversBuilder,
JobQueueObserversBuilder,
LivesObserversBuilder,
NodeJSObserversBuilder,
})
}
- registerMetrics () {
+ registerMetrics (options: { trackerServer: any }) {
if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
logger.info('Registering Open Telemetry metrics')
const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
viewersObserversBuilder.buildObservers()
+
+ const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
+ bittorrentTrackerObserversBuilder.buildObservers()
}
observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
return WEBSERVER.URL
},
+ getServerListeningConfig () {
+ return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
+ },
+
getServerConfig () {
return ServerConfigManager.Instance.getServerConfig()
}
},
getAuthUser: (res: express.Response) => {
- const user = res.locals.oauth?.token?.User
+ const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
if (!user) return undefined
return UserModel.loadByIdFull(user.id)
AP_CLEANER,
CONTACT_FORM_LIFETIME,
RESUMABLE_UPLOAD_SESSION_LIFETIME,
- TRACKER_RATE_LIMITS,
TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
- USER_EMAIL_VERIFY_LIFETIME,
+ EMAIL_VERIFY_LIFETIME,
USER_PASSWORD_CREATE_LIFETIME,
USER_PASSWORD_RESET_LIFETIME,
VIEW_LIFETIME,
/* ************ Email verification ************ */
- async setVerifyEmailVerificationString (userId: number) {
+ async setUserVerifyEmailVerificationString (userId: number) {
const generatedString = await generateRandomString(32)
- await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME)
+ await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
return generatedString
}
- async getVerifyEmailLink (userId: number) {
- return this.getValue(this.generateVerifyEmailKey(userId))
+ async getUserVerifyEmailLink (userId: number) {
+ return this.getValue(this.generateUserVerifyEmailKey(userId))
+ }
+
+ async setRegistrationVerifyEmailVerificationString (registrationId: number) {
+ const generatedString = await generateRandomString(32)
+
+ await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME)
+
+ return generatedString
+ }
+
+ async getRegistrationVerifyEmailLink (registrationId: number) {
+ return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId))
}
/* ************ Contact form per IP ************ */
return this.exists(this.generateIPViewKey(ip, videoUUID))
}
- /* ************ Tracker IP block ************ */
-
- setTrackerBlockIP (ip: string) {
- return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
- }
-
- async doesTrackerBlockIPExist (ip: string) {
- return this.exists(this.generateTrackerBlockIPKey(ip))
- }
-
/* ************ Video views stats ************ */
addVideoViewStats (videoId: number) {
return 'two-factor-request-' + userId + '-' + token
}
- private generateVerifyEmailKey (userId: number) {
- return 'verify-email-' + userId
+ private generateUserVerifyEmailKey (userId: number) {
+ return 'verify-email-user-' + userId
}
- private generateIPViewKey (ip: string, videoUUID: string) {
- return `views-${videoUUID}-${ip}`
+ private generateRegistrationVerifyEmailKey (registrationId: number) {
+ return 'verify-email-registration-' + registrationId
}
- private generateTrackerBlockIPKey (ip: string) {
- return `tracker-block-ip-${ip}`
+ private generateIPViewKey (ip: string, videoUUID: string) {
+ return `views-${videoUUID}-${ip}`
}
private generateContactFormKey (ip: string) {
async getServerConfig (ip?: string): Promise<ServerConfig> {
const { allowed } = await Hooks.wrapPromiseFun(
isSignupAllowed,
+
{
- ip
+ ip,
+ signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL
+ ? 'request-registration'
+ : 'direct-registration'
},
- 'filter:api.user.signup.allowed.result'
+
+ CONFIG.SIGNUP.REQUIRES_APPROVAL
+ ? 'filter:api.user.request-signup.allowed.result'
+ : 'filter:api.user.signup.allowed.result'
)
const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
allowed,
allowedForCurrentIP,
minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
+ requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
}
const isCidr = require('is-cidr')
-async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> {
+export type SignupMode = 'direct-registration' | 'request-registration'
+
+async function isSignupAllowed (options: {
+ signupMode: SignupMode
+
+ ip: string // For plugins
+ body?: any
+}): Promise<{ allowed: boolean, errorMessage?: string }> {
+ const { signupMode } = options
+
if (CONFIG.SIGNUP.ENABLED === false) {
return { allowed: false }
}
+ if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) {
+ return { allowed: false }
+ }
+
// No limit and signup is enabled
if (CONFIG.SIGNUP.LIMIT === -1) {
return { allowed: true }
await JobQueue.Instance.createJobWithChildren(parent, children)
} catch (err) {
- logger.error(`Failed to import channel ${channel.name}`, { err })
+ logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
channelSync.state = VideoChannelSyncState.FAILED
await channelSync.save()
}
import { AccountModel } from '../models/account/account'
import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
import { MAccountDefault, MChannelActor } from '../types/models'
-import { MUser, MUserDefault, MUserId } from '../types/models/user'
+import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user'
import { generateAndSaveActorKeys } from './activitypub/actors'
import { getLocalAccountActivityPubUrl } from './activitypub/url'
import { Emailer } from './emailer'
})
userCreated.Account = accountCreated
- const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames)
+ const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames })
const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
// ---------------------------------------------------------------------------
async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
- const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
- let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
+ const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
+ let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
- if (isPendingEmail) url += '&isPendingEmail=true'
+ if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
+
+ const to = isPendingEmail
+ ? user.pendingEmail
+ : user.email
- const email = isPendingEmail ? user.pendingEmail : user.email
const username = user.username
- Emailer.Instance.addVerifyEmailJob(username, email, url)
+ Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
+}
+
+async function sendVerifyRegistrationEmail (registration: MRegistration) {
+ const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
+ const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
+
+ const to = registration.email
+ const username = registration.username
+
+ Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
}
// ---------------------------------------------------------------------------
createApplicationActor,
createUserAccountAndChannelAndPlaylist,
createLocalAccountWithoutKeys,
+
sendVerifyUserEmail,
+ sendVerifyRegistrationEmail,
+
isAbleToUploadVideo,
buildUser
}
return UserNotificationSettingModel.create(values, { transaction: t })
}
-async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) {
+async function buildChannelAttributes (options: {
+ user: MUser
+ transaction?: Transaction
+ channelNames?: ChannelNames
+}) {
+ const { user, transaction, channelNames } = options
+
if (channelNames) return channelNames
const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
+import express from 'express'
import { cloneDeep } from 'lodash'
import * as Sequelize from 'sequelize'
-import express from 'express'
import { logger } from '@server/helpers/logger'
import { sequelizeTypescript } from '@server/initializers/database'
import { ResultList } from '../../shared/models'
import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
import { VideoCommentModel } from '../models/video/video-comment'
-import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models'
+import {
+ MAccountDefault,
+ MComment,
+ MCommentFormattable,
+ MCommentOwnerVideo,
+ MCommentOwnerVideoReply,
+ MVideoFullLight
+} from '../types/models'
import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
import { Hooks } from './plugins/hooks'
-async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) {
- const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
+async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
+ let videoCommentInstanceBefore: MCommentOwnerVideo
await sequelizeTypescript.transaction(async t => {
- if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
- await sendDeleteVideoComment(videoCommentInstance, t)
+ const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
+
+ videoCommentInstanceBefore = cloneDeep(comment)
+
+ if (comment.isOwned() || comment.Video.isOwned()) {
+ await sendDeleteVideoComment(comment, t)
}
- videoCommentInstance.markAsDeleted()
+ comment.markAsDeleted()
- await videoCommentInstance.save({ transaction: t })
- })
+ await comment.save({ transaction: t })
- logger.info('Video comment %d deleted.', videoCommentInstance.id)
+ logger.info('Video comment %d deleted.', comment.id)
+ })
Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
}
return savedComment
}
-function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree {
+function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
// Comments are sorted by id ASC
const comments = resultList.data
import LRUCache from 'lru-cache'
import { LRU_CACHE } from '@server/initializers/constants'
+import { MUserAccountUrl } from '@server/types/models'
+import { pick } from '@shared/core-utils'
import { buildUUID } from '@shared/extra-utils'
// ---------------------------------------------------------------------------
private static instance: VideoTokensManager
- private readonly lruCache = new LRUCache<string, string>({
+ private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
ttl: LRU_CACHE.VIDEO_TOKENS.TTL
})
private constructor () {}
- create (videoUUID: string) {
+ create (options: {
+ user: MUserAccountUrl
+ videoUUID: string
+ }) {
const token = buildUUID()
const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
- this.lruCache.set(token, videoUUID)
+ this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
return { token, expires }
}
const value = this.lruCache.get(options.token)
if (!value) return false
- return value === options.videoUUID
+ return value.videoUUID === options.videoUUID
+ }
+
+ getUserFromToken (options: {
+ token: string
+ }) {
+ const value = this.lruCache.get(options.token)
+ if (!value) return undefined
+
+ return value.user
}
static get Instance () {
import express from 'express'
-import { SortType } from '../models/utils'
const setDefaultSort = setDefaultSortFactory('-createdAt')
const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
const setDefaultSearchSort = setDefaultSortFactory('-match')
-
-function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
- const newSort: SortType = { sortModel: undefined, sortValue: '' }
-
- if (!req.query.sort) req.query.sort = '-createdAt'
-
- // Set model we want to sort onto
- if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
- req.query.sort === '-id' || req.query.sort === 'id') {
- // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
- newSort.sortModel = undefined
- } else {
- newSort.sortModel = 'Video'
- }
-
- newSort.sortValue = req.query.sort
-
- req.query.sort = newSort
-
- return next()
-}
+const setBlacklistSort = setDefaultSortFactory('-createdAt')
// ---------------------------------------------------------------------------
body('signup.enabled').isBoolean(),
body('signup.limit').isInt(),
body('signup.requiresEmailVerification').isBoolean(),
+ body('signup.requiresApproval').isBoolean(),
body('signup.minimumAge').isInt(),
body('admin.email').isEmail(),
export * from './sort'
export * from './static'
export * from './themes'
+export * from './user-email-verification'
export * from './user-history'
export * from './user-notifications'
+export * from './user-registrations'
export * from './user-subscriptions'
export * from './users'
export * from './videos'
--- /dev/null
+import express from 'express'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
+import { MRegistration } from '@server/types/models'
+import { forceNumber, pick } from '@shared/core-utils'
+import { HttpStatusCode } from '@shared/models'
+
+function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
+ const id = forceNumber(idArg)
+ return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
+}
+
+function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) {
+ return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse)
+}
+
+async function checkRegistrationHandlesDoNotAlreadyExist (options: {
+ username: string
+ channelHandle: string
+ email: string
+ res: express.Response
+}) {
+ const { res } = options
+
+ const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ]))
+
+ if (registration) {
+ res.fail({
+ status: HttpStatusCode.CONFLICT_409,
+ message: 'Registration with this username, channel name or email already exists.'
+ })
+ return false
+ }
+
+ return true
+}
+
+async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
+ const registration = await finder()
+
+ if (!registration) {
+ if (abortResponse === true) {
+ res.fail({
+ status: HttpStatusCode.NOT_FOUND_404,
+ message: 'User not found'
+ })
+ }
+
+ return false
+ }
+
+ res.locals.userRegistration = registration
+ return true
+}
+
+export {
+ checkRegistrationIdExist,
+ checkRegistrationEmailExist,
+ checkRegistrationHandlesDoNotAlreadyExist,
+ checkRegistrationExist
+}
return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
}
-async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
+async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
const user = await UserModel.loadByUsernameOrEmail(username, email)
if (user) {
export {
checkUserIdExist,
checkUserEmailExist,
- checkUserNameOrEmailDoesNotAlreadyExist,
+ checkUserNameOrEmailDoNotAlreadyExist,
checkUserExist
}
return checkCanSeeVideo(options)
}
- if (!video.hasPrivateStaticPath()) return true
-
const videoFileToken = req.query.videoFileToken
- if (!videoFileToken) {
- res.sendStatus(HttpStatusCode.FORBIDDEN_403)
- return false
- }
+ if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
+ const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
- if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
+ res.locals.videoFileToken = { user }
return true
}
+ if (!video.hasPrivateStaticPath()) return true
+
res.sendStatus(HttpStatusCode.FORBIDDEN_403)
return false
}
import express from 'express'
import { query } from 'express-validator'
-
import { SORTABLE_COLUMNS } from '../../initializers/constants'
import { areValidationErrors } from './shared'
+export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
+export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
+export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
+export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
+export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
+export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
+export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
+export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
+export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
+export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
+export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
+export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
+export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
+export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
+export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
+export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
+export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
+export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
+export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
+export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
+export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
+export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
+export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
+export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
+export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
+
+export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
+export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
+
+export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
+
+// ---------------------------------------------------------------------------
+
function checkSortFactory (columns: string[], tags: string[] = []) {
return checkSort(createSortableColumns(columns), tags)
}
return sortableColumns.concat(sortableColumnDesc)
}
-
-const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
-const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
-const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
-const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
-const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
-const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
-const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
-const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
-const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
-const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
-const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
-const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
-const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
-const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
-const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
-const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
-const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
-const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
-const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
-const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
-const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
-const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
-const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
-const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
-const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
-
-const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
-const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
-
-// ---------------------------------------------------------------------------
-
-export {
- adminUsersSortValidator,
- abusesSortValidator,
- videoChannelsSortValidator,
- videoImportsSortValidator,
- videoCommentsValidator,
- videosSearchSortValidator,
- videosSortValidator,
- blacklistSortValidator,
- accountsSortValidator,
- instanceFollowersSortValidator,
- instanceFollowingSortValidator,
- jobsSortValidator,
- videoCommentThreadsSortValidator,
- videoRatesSortValidator,
- userSubscriptionsSortValidator,
- availablePluginsSortValidator,
- videoChannelsSearchSortValidator,
- accountsBlocklistSortValidator,
- serversBlocklistSortValidator,
- userNotificationsSortValidator,
- videoPlaylistsSortValidator,
- videoRedundanciesSortValidator,
- videoPlaylistsSearchSortValidator,
- accountsFollowersSortValidator,
- videoChannelsFollowersSortValidator,
- videoChannelSyncsSortValidator,
- pluginsSortValidator
-}
--- /dev/null
+import express from 'express'
+import { body, param } from 'express-validator'
+import { toBooleanOrNull } from '@server/helpers/custom-validators/misc'
+import { HttpStatusCode } from '@shared/models'
+import { logger } from '../../helpers/logger'
+import { Redis } from '../../lib/redis'
+import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared'
+import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations'
+
+const usersAskSendVerifyEmailValidator = [
+ body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ const [ userExists, registrationExists ] = await Promise.all([
+ checkUserEmailExist(req.body.email, res, false),
+ checkRegistrationEmailExist(req.body.email, res, false)
+ ])
+
+ if (!userExists && !registrationExists) {
+ logger.debug('User or registration with email %s does not exist (asking verify email).', req.body.email)
+ // Do not leak our emails
+ return res.status(HttpStatusCode.NO_CONTENT_204).end()
+ }
+
+ if (res.locals.user?.pluginAuth) {
+ return res.fail({
+ status: HttpStatusCode.CONFLICT_409,
+ message: 'Cannot ask verification email of a user that uses a plugin authentication.'
+ })
+ }
+
+ return next()
+ }
+]
+
+const usersVerifyEmailValidator = [
+ param('id')
+ .isInt().not().isEmpty().withMessage('Should have a valid id'),
+
+ body('verificationString')
+ .not().isEmpty().withMessage('Should have a valid verification string'),
+ body('isPendingEmail')
+ .optional()
+ .customSanitizer(toBooleanOrNull),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+ if (!await checkUserIdExist(req.params.id, res)) return
+
+ const user = res.locals.user
+ const redisVerificationString = await Redis.Instance.getUserVerifyEmailLink(user.id)
+
+ if (redisVerificationString !== req.body.verificationString) {
+ return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
+ }
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+const registrationVerifyEmailValidator = [
+ param('registrationId')
+ .isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
+
+ body('verificationString')
+ .not().isEmpty().withMessage('Should have a valid verification string'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+ if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
+
+ const registration = res.locals.userRegistration
+ const redisVerificationString = await Redis.Instance.getRegistrationVerifyEmailLink(registration.id)
+
+ if (redisVerificationString !== req.body.verificationString) {
+ return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
+ }
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ usersAskSendVerifyEmailValidator,
+ usersVerifyEmailValidator,
+
+ registrationVerifyEmailValidator
+}
--- /dev/null
+import express from 'express'
+import { body, param, query, ValidationChain } from 'express-validator'
+import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc'
+import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration'
+import { CONFIG } from '@server/initializers/config'
+import { Hooks } from '@server/lib/plugins/hooks'
+import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models'
+import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users'
+import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
+import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup'
+import { ActorModel } from '../../models/actor/actor'
+import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared'
+import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations'
+
+const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
+
+const usersRequestRegistrationValidator = [
+ ...usersCommonRegistrationValidatorFactory([
+ body('registrationReason')
+ .custom(isRegistrationReasonValid)
+ ]),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ const body: UserRegistrationRequest = req.body
+
+ if (CONFIG.SIGNUP.REQUIRES_APPROVAL !== true) {
+ return res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message: 'Signup approval is not enabled on this instance'
+ })
+ }
+
+ const options = { username: body.username, email: body.email, channelHandle: body.channel?.name, res }
+ if (!await checkRegistrationHandlesDoNotAlreadyExist(options)) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+function ensureUserRegistrationAllowedFactory (signupMode: SignupMode) {
+ return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ const allowedParams = {
+ body: req.body,
+ ip: req.ip,
+ signupMode
+ }
+
+ const allowedResult = await Hooks.wrapPromiseFun(
+ isSignupAllowed,
+ allowedParams,
+
+ signupMode === 'direct-registration'
+ ? 'filter:api.user.signup.allowed.result'
+ : 'filter:api.user.request-signup.allowed.result'
+ )
+
+ if (allowedResult.allowed === false) {
+ return res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ message: allowedResult.errorMessage || 'User registration is not enabled, user limit is reached or registration requires approval.'
+ })
+ }
+
+ return next()
+ }
+}
+
+const ensureUserRegistrationAllowedForIP = [
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ const allowed = isSignupAllowedForCurrentIP(req.ip)
+
+ if (allowed === false) {
+ return res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ message: 'You are not on a network authorized for registration.'
+ })
+ }
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+const acceptOrRejectRegistrationValidator = [
+ param('registrationId')
+ .custom(isIdValid),
+
+ body('moderationResponse')
+ .custom(isRegistrationModerationResponseValid),
+
+ body('preventEmailDelivery')
+ .optional()
+ .customSanitizer(toBooleanOrNull)
+ .custom(isBooleanValid).withMessage('Should have preventEmailDelivery boolean'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+ if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
+
+ if (res.locals.userRegistration.state !== UserRegistrationState.PENDING) {
+ return res.fail({
+ status: HttpStatusCode.CONFLICT_409,
+ message: 'This registration is already accepted or rejected.'
+ })
+ }
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+const getRegistrationValidator = [
+ param('registrationId')
+ .custom(isIdValid),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+ if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+const listRegistrationsValidator = [
+ query('search')
+ .optional()
+ .custom(exists),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ usersDirectRegistrationValidator,
+ usersRequestRegistrationValidator,
+
+ ensureUserRegistrationAllowedFactory,
+ ensureUserRegistrationAllowedForIP,
+
+ getRegistrationValidator,
+ listRegistrationsValidator,
+
+ acceptOrRejectRegistrationValidator
+}
+
+// ---------------------------------------------------------------------------
+
+function usersCommonRegistrationValidatorFactory (additionalValidationChain: ValidationChain[] = []) {
+ return [
+ body('username')
+ .custom(isUserUsernameValid),
+ body('password')
+ .custom(isUserPasswordValid),
+ body('email')
+ .isEmail(),
+ body('displayName')
+ .optional()
+ .custom(isUserDisplayNameValid),
+
+ body('channel.name')
+ .optional()
+ .custom(isVideoChannelUsernameValid),
+ body('channel.displayName')
+ .optional()
+ .custom(isVideoChannelDisplayNameValid),
+
+ ...additionalValidationChain,
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res, { omitBodyLog: true })) return
+
+ const body: UserRegister | UserRegistrationRequest = req.body
+
+ if (!await checkUserNameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return
+
+ if (body.channel) {
+ if (!body.channel.name || !body.channel.displayName) {
+ return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
+ }
+
+ if (body.channel.name === body.username) {
+ return res.fail({ message: 'Channel name cannot be the same as user username.' })
+ }
+
+ const existing = await ActorModel.loadLocalByName(body.channel.name)
+ if (existing) {
+ return res.fail({
+ status: HttpStatusCode.CONFLICT_409,
+ message: `Channel with name ${body.channel.name} already exists.`
+ })
+ }
+ }
+
+ return next()
+ }
+ ]
+}
import express from 'express'
import { body, param, query } from 'express-validator'
-import { Hooks } from '@server/lib/plugins/hooks'
import { forceNumber } from '@shared/core-utils'
-import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models'
+import { HttpStatusCode, UserRight, UserRole } from '@shared/models'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import {
isUserVideoQuotaValid,
isUserVideosHistoryEnabledValid
} from '../../helpers/custom-validators/users'
-import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
+import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
import { logger } from '../../helpers/logger'
import { isThemeRegistered } from '../../lib/plugins/theme-utils'
import { Redis } from '../../lib/redis'
-import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
import { ActorModel } from '../../models/actor/actor'
import {
areValidationErrors,
checkUserEmailExist,
checkUserIdExist,
- checkUserNameOrEmailDoesNotAlreadyExist,
+ checkUserNameOrEmailDoNotAlreadyExist,
doesVideoChannelIdExist,
doesVideoExist,
isValidVideoIdParam
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { omitBodyLog: true })) return
- if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
+ if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return
const authUser = res.locals.oauth.token.User
if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
}
]
-const usersRegisterValidator = [
- body('username')
- .custom(isUserUsernameValid),
- body('password')
- .custom(isUserPasswordValid),
- body('email')
- .isEmail(),
- body('displayName')
- .optional()
- .custom(isUserDisplayNameValid),
-
- body('channel.name')
- .optional()
- .custom(isVideoChannelUsernameValid),
- body('channel.displayName')
- .optional()
- .custom(isVideoChannelDisplayNameValid),
-
- async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- if (areValidationErrors(req, res, { omitBodyLog: true })) return
- if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
-
- const body: UserRegister = req.body
- if (body.channel) {
- if (!body.channel.name || !body.channel.displayName) {
- return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
- }
-
- if (body.channel.name === body.username) {
- return res.fail({ message: 'Channel name cannot be the same as user username.' })
- }
-
- const existing = await ActorModel.loadLocalByName(body.channel.name)
- if (existing) {
- return res.fail({
- status: HttpStatusCode.CONFLICT_409,
- message: `Channel with name ${body.channel.name} already exists.`
- })
- }
- }
-
- return next()
- }
-]
-
const usersRemoveValidator = [
param('id')
.custom(isIdValid),
}
]
-const ensureUserRegistrationAllowed = [
- async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- const allowedParams = {
- body: req.body,
- ip: req.ip
- }
-
- const allowedResult = await Hooks.wrapPromiseFun(
- isSignupAllowed,
- allowedParams,
- 'filter:api.user.signup.allowed.result'
- )
-
- if (allowedResult.allowed === false) {
- return res.fail({
- status: HttpStatusCode.FORBIDDEN_403,
- message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.'
- })
- }
-
- return next()
- }
-]
-
-const ensureUserRegistrationAllowedForIP = [
- (req: express.Request, res: express.Response, next: express.NextFunction) => {
- const allowed = isSignupAllowedForCurrentIP(req.ip)
-
- if (allowed === false) {
- return res.fail({
- status: HttpStatusCode.FORBIDDEN_403,
- message: 'You are not on a network authorized for registration.'
- })
- }
-
- return next()
- }
-]
-
const usersAskResetPasswordValidator = [
body('email')
.isEmail(),
}
]
-const usersAskSendVerifyEmailValidator = [
- body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
-
- async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- if (areValidationErrors(req, res)) return
-
- const exists = await checkUserEmailExist(req.body.email, res, false)
- if (!exists) {
- logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
- // Do not leak our emails
- return res.status(HttpStatusCode.NO_CONTENT_204).end()
- }
-
- if (res.locals.user.pluginAuth) {
- return res.fail({
- status: HttpStatusCode.CONFLICT_409,
- message: 'Cannot ask verification email of a user that uses a plugin authentication.'
- })
- }
-
- return next()
- }
-]
-
-const usersVerifyEmailValidator = [
- param('id')
- .isInt().not().isEmpty().withMessage('Should have a valid id'),
-
- body('verificationString')
- .not().isEmpty().withMessage('Should have a valid verification string'),
- body('isPendingEmail')
- .optional()
- .customSanitizer(toBooleanOrNull),
-
- async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- if (areValidationErrors(req, res)) return
- if (!await checkUserIdExist(req.params.id, res)) return
-
- const user = res.locals.user
- const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
-
- if (redisVerificationString !== req.body.verificationString) {
- return res.fail({
- status: HttpStatusCode.FORBIDDEN_403,
- message: 'Invalid verification string.'
- })
- }
-
- return next()
- }
-]
-
const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
return [
body('currentPassword').optional().custom(exists),
usersListValidator,
usersAddValidator,
deleteMeValidator,
- usersRegisterValidator,
usersBlockingValidator,
usersRemoveValidator,
usersUpdateValidator,
usersUpdateMeValidator,
usersVideoRatingValidator,
usersCheckCurrentPasswordFactory,
- ensureUserRegistrationAllowed,
- ensureUserRegistrationAllowedForIP,
usersGetValidator,
usersVideosValidator,
usersAskResetPasswordValidator,
usersResetPasswordValidator,
- usersAskSendVerifyEmailValidator,
- usersVerifyEmailValidator,
userAutocompleteValidator,
ensureAuthUserOwnsAccountValidator,
ensureCanModerateUser,
import { AbuseMessage } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
import { AbuseModel } from './abuse'
@Table({
import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
import { ThumbnailModel } from '../video/thumbnail'
import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
import { VideoBlacklistModel } from '../video/video-blacklist'
import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
-import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
+import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
import { VideoAbuseModel } from './video-abuse'
import { VideoCommentAbuseModel } from './video-comment-abuse'
import { exists } from '@server/helpers/custom-validators/misc'
import { forceNumber } from '@shared/core-utils'
import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
-import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils'
+import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
export type BuildAbusesQueryOptions = {
start: number
}
function buildAbuseOrder (value: string) {
- const { direction, field } = buildDirectionAndField(value)
+ const { direction, field } = buildSortDirectionAndField(value)
return `ORDER BY "abuse"."${field}" ${direction}`
}
import { AccountBlock } from '../../../shared/models'
import { ActorModel } from '../actor/actor'
import { ServerModel } from '../server/server'
-import { createSafeIn, getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../shared'
import { AccountModel } from './account'
@Table({
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
import { ActorModel } from '../actor/actor'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
import { VideoModel } from '../video/video'
import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
import { AccountModel } from './account'
Table,
UpdatedAt
} from 'sequelize-typescript'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
import { AttributesOnly } from '@shared/typescript-utils'
import { Account, AccountSummary } from '../../../shared/models/actors'
import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
import { ServerModel } from '../server/server'
import { ServerBlocklistModel } from '../server/server-blocklist'
import { UserModel } from '../user/user'
-import { getSort, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
import { VideoModel } from '../video/video'
import { VideoChannelModel } from '../video/video-channel'
import { VideoCommentModel } from '../video/video-comment'
return undefined
}
+ // ---------------------------------------------------------------------------
+
+ static getSQLAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
return AccountModel.findByPk(id, { transaction })
}
import { AccountModel } from '../account/account'
import { ServerModel } from '../server/server'
import { doesExist } from '../shared/query'
-import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
import { VideoChannelModel } from '../video/video-channel'
import { ActorModel, unusedActorAttributesForAPI } from './actor'
import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
})
}
+ // ---------------------------------------------------------------------------
+
+ static getSQLAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
/*
* @deprecated Use `findOrCreateCustom` instead
*/
`WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
`LIMIT 1`
- return doesExist(query, { actorId, followerActorId })
+ return doesExist(this.sequelize, query, { actorId, followerActorId })
}
static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
-import { throwIfNotValid } from '../utils'
+import { buildSQLAttributes, throwIfNotValid } from '../shared'
import { ActorModel } from './actor'
@Table({
.catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
}
+ // ---------------------------------------------------------------------------
+
+ static getSQLAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
static loadByName (filename: string) {
const query = {
where: {
} from 'sequelize-typescript'
import { activityPubContextify } from '@server/lib/activitypub/context'
import { getBiggestActorImage } from '@server/lib/actor-image'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountModel } from '../account/account'
import { getServerActor } from '../application/application'
import { ServerModel } from '../server/server'
-import { isOutdated, throwIfNotValid } from '../utils'
+import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
import { VideoModel } from '../video/video'
import { VideoChannelModel } from '../video/video-channel'
import { ActorFollowModel } from './actor-follow'
FULL = 'FULL'
}
-export const unusedActorAttributesForAPI = [
+export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
'publicKey',
'privateKey',
'inboxUrl',
})
VideoChannel: VideoChannelModel
+ // ---------------------------------------------------------------------------
+
+ static getSQLAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix
+ })
+ }
+
+ static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix,
+ excludeAttributes: unusedActorAttributesForAPI
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
static async load (id: number): Promise<MActor> {
const actorServer = await getServerActor()
if (id === actorServer.id) return actorServer
import { Sequelize } from 'sequelize'
import { ModelBuilder } from '@server/models/shared'
-import { parseRowCountResult } from '@server/models/utils'
import { MActorFollowActorsDefault } from '@server/types/models'
import { ActivityPubActorType, FollowState } from '@shared/models'
+import { parseRowCountResult } from '../../shared'
import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
export interface ListFollowersOptions {
import { Sequelize } from 'sequelize'
import { ModelBuilder } from '@server/models/shared'
-import { parseRowCountResult } from '@server/models/utils'
import { MActorFollowActorsDefault } from '@server/types/models'
import { ActivityPubActorType, FollowState } from '@shared/models'
+import { parseRowCountResult } from '../../shared'
import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
export interface ListFollowingOptions {
+import { logger } from '@server/helpers/logger'
+import { Memoize } from '@server/helpers/memoize'
+import { ServerModel } from '@server/models/server/server'
+import { ActorModel } from '../../actor'
+import { ActorFollowModel } from '../../actor-follow'
+import { ActorImageModel } from '../../actor-image'
+
export class ActorFollowTableAttributes {
+ @Memoize()
getFollowAttributes () {
- return [
- '"ActorFollowModel"."id"',
- '"ActorFollowModel"."state"',
- '"ActorFollowModel"."score"',
- '"ActorFollowModel"."url"',
- '"ActorFollowModel"."actorId"',
- '"ActorFollowModel"."targetActorId"',
- '"ActorFollowModel"."createdAt"',
- '"ActorFollowModel"."updatedAt"'
- ].join(', ')
+ logger.error('coucou')
+
+ return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
}
+ @Memoize()
getActorAttributes (actorTableName: string) {
- return [
- `"${actorTableName}"."id" AS "${actorTableName}.id"`,
- `"${actorTableName}"."type" AS "${actorTableName}.type"`,
- `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
- `"${actorTableName}"."url" AS "${actorTableName}.url"`,
- `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
- `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
- `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
- `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
- `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
- `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
- `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
- `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
- `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
- `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
- `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
- `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
- `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
- ].join(', ')
+ return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
}
+ @Memoize()
getServerAttributes (actorTableName: string) {
- return [
- `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
- `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
- `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
- `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
- `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
- ].join(', ')
+ return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
}
+ @Memoize()
getAvatarAttributes (actorTableName: string) {
- return [
- `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
- `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
- `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
- `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
- `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
- `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
- `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
- `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
- `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
- `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
- ].join(', ')
+ return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
}
}
import { Sequelize } from 'sequelize'
import { AbstractRunQuery } from '@server/models/shared'
-import { getInstanceFollowsSort } from '@server/models/utils'
import { ActorImageType } from '@shared/models'
+import { getInstanceFollowsSort } from '../../../shared'
import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
type BaseOptions = {
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
import { ActorModel } from '../actor/actor'
import { ServerModel } from '../server/server'
-import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
+import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
import { VideoModel } from '../video/video'
import { VideoChannelModel } from '../video/video-channel'
isPluginStableVersionValid,
isPluginTypeValid
} from '../../helpers/custom-validators/plugins'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
@DefaultScope(() => ({
attributes: {
import { ServerBlock } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountModel } from '../account/account'
-import { createSafeIn, getSort, searchAttribute } from '../utils'
+import { createSafeIn, getSort, searchAttribute } from '../shared'
import { ServerModel } from './server'
enum ScopeNames {
import { AttributesOnly } from '@shared/typescript-utils'
import { isHostValid } from '../../helpers/custom-validators/servers'
import { ActorModel } from '../actor/actor'
-import { throwIfNotValid } from '../utils'
+import { buildSQLAttributes, throwIfNotValid } from '../shared'
import { ServerBlocklistModel } from './server-blocklist'
@Table({
})
BlockedBy: ServerBlocklistModel[]
+ // ---------------------------------------------------------------------------
+
+ static getSQLAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
static load (id: number, transaction?: Transaction): Promise<MServer> {
const query = {
where: {
export * from './abstract-run-query'
export * from './model-builder'
+export * from './model-cache'
export * from './query'
+export * from './sequelize-helpers'
+export * from './sort'
+export * from './sql'
export * from './update'
import { isPlainObject } from 'lodash'
-import { Model as SequelizeModel, Sequelize } from 'sequelize'
+import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
import { logger } from '@server/helpers/logger'
+/**
+ *
+ * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
+ *
+ * In order to sequelize to correctly build the JSON this class will ingest,
+ * the columns selected in the raw query should be in the following form:
+ * * All tables must be Pascal Cased (for example "VideoChannel")
+ * * Root table must end with `Model` (for example "VideoCommentModel")
+ * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
+ * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
+ * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
+ * * Selected columns must be renamed to contain the JSON path:
+ * * "videoComment"."id": "VideoCommentModel"."id"
+ * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
+ * * All tables must contain the row id
+ */
+
export class ModelBuilder <T extends SequelizeModel> {
private readonly modelRegistry = new Map<string, T>()
'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
{ existing: this.sequelize.modelManager.all.map(m => m.name) }
)
- return undefined
+ return { created: false, model: null }
}
- // FIXME: typings
- const model = new (Model as any)(json)
+ const model = Model.build(json, { raw: true, isNewRecord: false })
+
this.modelRegistry.set(registryKey, model)
return { created: true, model }
}
private findModelBuilder (modelName: string) {
- return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
+ return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
}
private buildSequelizeModelName (modelName: string) {
-import { BindOrReplacements, QueryTypes } from 'sequelize'
-import { sequelizeTypescript } from '@server/initializers/database'
+import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
+import validator from 'validator'
+import { forceNumber } from '@shared/core-utils'
-function doesExist (query: string, bind?: BindOrReplacements) {
+function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
const options = {
type: QueryTypes.SELECT as QueryTypes.SELECT,
bind,
raw: true
}
- return sequelizeTypescript.query(query, options)
+ return sequelize.query(query, options)
.then(results => results.length === 1)
}
+function createSimilarityAttribute (col: string, value: string) {
+ return Sequelize.fn(
+ 'similarity',
+
+ searchTrigramNormalizeCol(col),
+
+ searchTrigramNormalizeValue(value)
+ )
+}
+
+function buildWhereIdOrUUID (id: number | string) {
+ return validator.isInt('' + id) ? { id } : { uuid: id }
+}
+
+function parseAggregateResult (result: any) {
+ if (!result) return 0
+
+ const total = forceNumber(result)
+ if (isNaN(total)) return 0
+
+ return total
+}
+
+function parseRowCountResult (result: any) {
+ if (result.length !== 0) return result[0].total
+
+ return 0
+}
+
+function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
+ return toEscape.map(t => {
+ return t === null
+ ? null
+ : sequelize.escape('' + t)
+ }).concat(additionalUnescaped).join(', ')
+}
+
+function searchAttribute (sourceField?: string, targetField?: string) {
+ if (!sourceField) return {}
+
+ return {
+ [targetField]: {
+ // FIXME: ts error
+ [Op.iLike as any]: `%${sourceField}%`
+ }
+ }
+}
+
export {
- doesExist
+ doesExist,
+ createSimilarityAttribute,
+ buildWhereIdOrUUID,
+ parseAggregateResult,
+ parseRowCountResult,
+ createSafeIn,
+ searchAttribute
+}
+
+// ---------------------------------------------------------------------------
+
+function searchTrigramNormalizeValue (value: string) {
+ return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
+}
+
+function searchTrigramNormalizeCol (col: string) {
+ return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
}
--- /dev/null
+import { Sequelize } from 'sequelize'
+
+function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
+ if (!model.createdAt || !model.updatedAt) {
+ throw new Error('Miss createdAt & updatedAt attributes to model')
+ }
+
+ const now = Date.now()
+ const createdAtTime = model.createdAt.getTime()
+ const updatedAtTime = model.updatedAt.getTime()
+
+ return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
+}
+
+function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
+ if (nullable && (value === null || value === undefined)) return
+
+ if (validator(value) === false) {
+ throw new Error(`"${value}" is not a valid ${fieldName}.`)
+ }
+}
+
+function buildTrigramSearchIndex (indexName: string, attribute: string) {
+ return {
+ name: indexName,
+ // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
+ fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
+ using: 'gin',
+ operator: 'gin_trgm_ops'
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ throwIfNotValid,
+ buildTrigramSearchIndex,
+ isOutdated
+}
--- /dev/null
+import { literal, OrderItem, Sequelize } from 'sequelize'
+
+// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
+function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+
+ let finalField: string | ReturnType<typeof Sequelize.col>
+
+ if (field.toLowerCase() === 'match') { // Search
+ finalField = Sequelize.col('similarity')
+ } else {
+ finalField = field
+ }
+
+ return [ [ finalField, direction ], lastSort ]
+}
+
+function getAdminUsersSort (value: string): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+
+ let finalField: string | ReturnType<typeof Sequelize.col>
+
+ if (field === 'videoQuotaUsed') { // Users list
+ finalField = Sequelize.col('videoQuotaUsed')
+ } else {
+ finalField = field
+ }
+
+ const nullPolicy = direction === 'ASC'
+ ? 'NULLS FIRST'
+ : 'NULLS LAST'
+
+ // FIXME: typings
+ return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
+}
+
+function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+
+ if (field.toLowerCase() === 'name') {
+ return [ [ 'displayName', direction ], lastSort ]
+ }
+
+ return getSort(value, lastSort)
+}
+
+function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+
+ if (field.toLowerCase() === 'trending') { // Sort by aggregation
+ return [
+ [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
+
+ [ Sequelize.col('VideoModel.views'), direction ],
+
+ lastSort
+ ]
+ } else if (field === 'publishedAt') {
+ return [
+ [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
+
+ [ Sequelize.col('VideoModel.publishedAt'), direction ],
+
+ lastSort
+ ]
+ }
+
+ let finalField: string | ReturnType<typeof Sequelize.col>
+
+ // Alias
+ if (field.toLowerCase() === 'match') { // Search
+ finalField = Sequelize.col('similarity')
+ } else {
+ finalField = field
+ }
+
+ const firstSort: OrderItem = typeof finalField === 'string'
+ ? finalField.split('.').concat([ direction ]) as OrderItem
+ : [ finalField, direction ]
+
+ return [ firstSort, lastSort ]
+}
+
+function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+
+ const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
+
+ if (videoFields.has(field)) {
+ return [
+ [ literal(`"Video.${field}" ${direction}`) ],
+ lastSort
+ ] as OrderItem[]
+ }
+
+ return getSort(value, lastSort)
+}
+
+function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+
+ if (field === 'redundancyAllowed') {
+ return [
+ [ 'ActorFollowing.Server.redundancyAllowed', direction ],
+ lastSort
+ ]
+ }
+
+ return getSort(value, lastSort)
+}
+
+function getChannelSyncSort (value: string): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(value)
+ if (field.toLowerCase() === 'videochannel') {
+ return [
+ [ literal('"VideoChannel.name"'), direction ]
+ ]
+ }
+ return [ [ field, direction ] ]
+}
+
+function buildSortDirectionAndField (value: string) {
+ let field: string
+ let direction: 'ASC' | 'DESC'
+
+ if (value.substring(0, 1) === '-') {
+ direction = 'DESC'
+ field = value.substring(1)
+ } else {
+ direction = 'ASC'
+ field = value
+ }
+
+ return { direction, field }
+}
+
+export {
+ buildSortDirectionAndField,
+ getPlaylistSort,
+ getSort,
+ getAdminUsersSort,
+ getVideoSort,
+ getBlacklistSort,
+ getChannelSyncSort,
+ getInstanceFollowsSort
+}
--- /dev/null
+import { literal, Model, ModelStatic } from 'sequelize'
+import { forceNumber } from '@shared/core-utils'
+import { AttributesOnly } from '@shared/typescript-utils'
+
+function buildLocalAccountIdsIn () {
+ return literal(
+ '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
+ )
+}
+
+function buildLocalActorIdsIn () {
+ return literal(
+ '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
+ )
+}
+
+function buildBlockedAccountSQL (blockerIds: number[]) {
+ const blockerIdsString = blockerIds.join(', ')
+
+ return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
+ ' UNION ' +
+ 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
+ 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
+ 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
+}
+
+function buildServerIdsFollowedBy (actorId: any) {
+ const actorIdNumber = forceNumber(actorId)
+
+ return '(' +
+ 'SELECT "actor"."serverId" FROM "actorFollow" ' +
+ 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
+ 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
+ ')'
+}
+
+function buildSQLAttributes<M extends Model> (options: {
+ model: ModelStatic<M>
+ tableName: string
+
+ excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
+ aliasPrefix?: string
+}) {
+ const { model, tableName, aliasPrefix, excludeAttributes } = options
+
+ const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
+
+ return attributes
+ .filter(a => {
+ if (!excludeAttributes) return true
+ if (excludeAttributes.includes(a)) return false
+
+ return true
+ })
+ .map(a => {
+ return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
+ })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ buildSQLAttributes,
+ buildBlockedAccountSQL,
+ buildServerIdsFollowedBy,
+ buildLocalAccountIdsIn,
+ buildLocalActorIdsIn
+}
-import { QueryTypes, Transaction } from 'sequelize'
-import { sequelizeTypescript } from '@server/initializers/database'
+import { QueryTypes, Sequelize, Transaction } from 'sequelize'
// Sequelize always skip the update if we only update updatedAt field
-function setAsUpdated (table: string, id: number, transaction?: Transaction) {
- return sequelizeTypescript.query(
+function setAsUpdated (options: {
+ sequelize: Sequelize
+ table: string
+ id: number
+ transaction?: Transaction
+}) {
+ const { sequelize, table, id, transaction } = options
+
+ return sequelize.query(
`UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
{
replacements: { table, id, updatedAt: new Date() },
import { Sequelize } from 'sequelize'
import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
-import { getSort } from '@server/models/utils'
import { UserNotificationModelForApi } from '@server/types/models'
import { ActorImageType } from '@shared/models'
+import { getSort } from '../../shared'
export interface ListNotificationsOptions {
userId: number
"Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type",
"Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
"Account->Actor->Server"."id" AS "Account.Actor.Server.id",
- "Account->Actor->Server"."host" AS "Account.Actor.Server.host"`
+ "Account->Actor->Server"."host" AS "Account.Actor.Server.host",
+ "UserRegistration"."id" AS "UserRegistration.id",
+ "UserRegistration"."username" AS "UserRegistration.username"`
}
private getJoins () {
ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
) ON "UserNotificationModel"."videoId" = "Video"."id"
- LEFT JOIN (
- "videoComment" AS "VideoComment"
- INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
- INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
- LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
- ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
- AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
- LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
- ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
- INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
- ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
+ LEFT JOIN (
+ "videoComment" AS "VideoComment"
+ INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
+ INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
+ LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
+ ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
+ AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
+ LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
+ ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
+ INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
+ ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
+
+ LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
+ LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
+ LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
+ LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
+ LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
+ ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
+ LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
+ ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
+ LEFT JOIN (
+ "account" AS "Abuse->FlaggedAccount"
+ INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
+ LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
+ ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
+ AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
+ LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
+ ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
+ ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
- LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
- LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
- LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
- LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
- LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
- ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
- LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
- ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
- LEFT JOIN (
- "account" AS "Abuse->FlaggedAccount"
- INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
- LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
- ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
- AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
- LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
- ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
- ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
+ LEFT JOIN (
+ "videoBlacklist" AS "VideoBlacklist"
+ INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
+ ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
- LEFT JOIN (
- "videoBlacklist" AS "VideoBlacklist"
- INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
- ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
+ LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
+ LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
- LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
- LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
+ LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
- LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
+ LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
- LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
+ LEFT JOIN (
+ "actorFollow" AS "ActorFollow"
+ INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
+ INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
+ ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
+ LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
+ ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
+ AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
+ LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
+ ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
+ INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
+ LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
+ ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
+ LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
+ ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
+ LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
+ ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
+ ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
- LEFT JOIN (
- "actorFollow" AS "ActorFollow"
- INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
- INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
- ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
- LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
- ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
- AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
- LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
- ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
- INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
- LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
- ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
- LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
- ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
- LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
- ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
- ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
+ LEFT JOIN (
+ "account" AS "Account"
+ INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
+ LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
+ ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
+ AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
+ LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
+ ) ON "UserNotificationModel"."accountId" = "Account"."id"
- LEFT JOIN (
- "account" AS "Account"
- INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
- LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
- ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
- AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
- LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
- ) ON "UserNotificationModel"."accountId" = "Account"."id"`
+ LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"`
}
}
import { AttributesOnly } from '@shared/typescript-utils'
import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
import { UserModel } from './user'
@Table({
import { ActorFollowModel } from '../actor/actor-follow'
import { ApplicationModel } from '../application/application'
import { PluginModel } from '../server/plugin'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
import { VideoModel } from '../video/video'
import { VideoBlacklistModel } from '../video/video-blacklist'
import { VideoCommentModel } from '../video/video-comment'
import { VideoImportModel } from '../video/video-import'
import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
import { UserModel } from './user'
+import { UserRegistrationModel } from './user-registration'
@Table({
tableName: 'userNotification',
[Op.ne]: null
}
}
+ },
+ {
+ fields: [ 'userRegistrationId' ],
+ where: {
+ userRegistrationId: {
+ [Op.ne]: null
+ }
+ }
}
] as (ModelIndexesOptions & { where?: WhereOptions })[]
})
})
Application: ApplicationModel
+ @ForeignKey(() => UserRegistrationModel)
+ @Column
+ userRegistrationId: number
+
+ @BelongsTo(() => UserRegistrationModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'cascade'
+ })
+ UserRegistration: UserRegistrationModel
+
static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
const where = { userId }
? { latestVersion: this.Application.latestPeerTubeVersion }
: undefined
+ const registration = this.UserRegistration
+ ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
+ : undefined
+
return {
id: this.id,
type: this.type,
actorFollow,
plugin,
peertube,
+ registration,
createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString()
}
--- /dev/null
+import { FindOptions, Op, WhereOptions } from 'sequelize'
+import {
+ AllowNull,
+ BeforeCreate,
+ BelongsTo,
+ Column,
+ CreatedAt,
+ DataType,
+ ForeignKey,
+ Is,
+ IsEmail,
+ Model,
+ Table,
+ UpdatedAt
+} from 'sequelize-typescript'
+import {
+ isRegistrationModerationResponseValid,
+ isRegistrationReasonValid,
+ isRegistrationStateValid
+} from '@server/helpers/custom-validators/user-registration'
+import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
+import { cryptPassword } from '@server/helpers/peertube-crypto'
+import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
+import { MRegistration, MRegistrationFormattable } from '@server/types/models'
+import { UserRegistration, UserRegistrationState } from '@shared/models'
+import { AttributesOnly } from '@shared/typescript-utils'
+import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
+import { getSort, throwIfNotValid } from '../shared'
+import { UserModel } from './user'
+
+@Table({
+ tableName: 'userRegistration',
+ indexes: [
+ {
+ fields: [ 'username' ],
+ unique: true
+ },
+ {
+ fields: [ 'email' ],
+ unique: true
+ },
+ {
+ fields: [ 'channelHandle' ],
+ unique: true
+ },
+ {
+ fields: [ 'userId' ],
+ unique: true
+ }
+ ]
+})
+export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
+
+ @AllowNull(false)
+ @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
+ @Column
+ state: UserRegistrationState
+
+ @AllowNull(false)
+ @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
+ @Column(DataType.TEXT)
+ registrationReason: string
+
+ @AllowNull(true)
+ @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
+ @Column(DataType.TEXT)
+ moderationResponse: string
+
+ @AllowNull(true)
+ @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
+ @Column
+ password: string
+
+ @AllowNull(false)
+ @Column
+ username: string
+
+ @AllowNull(false)
+ @IsEmail
+ @Column(DataType.STRING(400))
+ email: string
+
+ @AllowNull(true)
+ @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
+ @Column
+ emailVerified: boolean
+
+ @AllowNull(true)
+ @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
+ @Column
+ accountDisplayName: string
+
+ @AllowNull(true)
+ @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
+ @Column
+ channelHandle: string
+
+ @AllowNull(true)
+ @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
+ @Column
+ channelDisplayName: string
+
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @ForeignKey(() => UserModel)
+ @Column
+ userId: number
+
+ @BelongsTo(() => UserModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'SET NULL'
+ })
+ User: UserModel
+
+ @BeforeCreate
+ static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
+ instance.password = await cryptPassword(instance.password)
+ }
+
+ static load (id: number): Promise<MRegistration> {
+ return UserRegistrationModel.findByPk(id)
+ }
+
+ static loadByEmail (email: string): Promise<MRegistration> {
+ const query = {
+ where: { email }
+ }
+
+ return UserRegistrationModel.findOne(query)
+ }
+
+ static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
+ const query = {
+ where: {
+ [Op.or]: [
+ { email: emailOrUsername },
+ { username: emailOrUsername }
+ ]
+ }
+ }
+
+ return UserRegistrationModel.findOne(query)
+ }
+
+ static loadByEmailOrHandle (options: {
+ email: string
+ username: string
+ channelHandle?: string
+ }): Promise<MRegistration> {
+ const { email, username, channelHandle } = options
+
+ let or: WhereOptions = [
+ { email },
+ { channelHandle: username },
+ { username }
+ ]
+
+ if (channelHandle) {
+ or = or.concat([
+ { username: channelHandle },
+ { channelHandle }
+ ])
+ }
+
+ const query = {
+ where: {
+ [Op.or]: or
+ }
+ }
+
+ return UserRegistrationModel.findOne(query)
+ }
+
+ // ---------------------------------------------------------------------------
+
+ static listForApi (options: {
+ start: number
+ count: number
+ sort: string
+ search?: string
+ }) {
+ const { start, count, sort, search } = options
+
+ const where: WhereOptions = {}
+
+ if (search) {
+ Object.assign(where, {
+ [Op.or]: [
+ {
+ email: {
+ [Op.iLike]: '%' + search + '%'
+ }
+ },
+ {
+ username: {
+ [Op.iLike]: '%' + search + '%'
+ }
+ }
+ ]
+ })
+ }
+
+ const query: FindOptions = {
+ offset: start,
+ limit: count,
+ order: getSort(sort),
+ where,
+ include: [
+ {
+ model: UserModel.unscoped(),
+ required: false
+ }
+ ]
+ }
+
+ return Promise.all([
+ UserRegistrationModel.count(query),
+ UserRegistrationModel.findAll<MRegistrationFormattable>(query)
+ ]).then(([ total, data ]) => ({ total, data }))
+ }
+
+ // ---------------------------------------------------------------------------
+
+ toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
+ return {
+ id: this.id,
+
+ state: {
+ id: this.state,
+ label: USER_REGISTRATION_STATES[this.state]
+ },
+
+ registrationReason: this.registrationReason,
+ moderationResponse: this.moderationResponse,
+
+ username: this.username,
+ email: this.email,
+ emailVerified: this.emailVerified,
+
+ accountDisplayName: this.accountDisplayName,
+
+ channelHandle: this.channelHandle,
+ channelDisplayName: this.channelDisplayName,
+
+ createdAt: this.createdAt,
+ updatedAt: this.updatedAt,
+
+ user: this.User
+ ? { id: this.User.id }
+ : null
+ }
+ }
+}
MUserNotifSettingChannelDefault,
MUserWithNotificationSetting
} from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
import { AttributesOnly } from '@shared/typescript-utils'
import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
import { ActorFollowModel } from '../actor/actor-follow'
import { ActorImageModel } from '../actor/actor-image'
import { OAuthTokenModel } from '../oauth/oauth-token'
-import { getAdminUsersSort, throwIfNotValid } from '../utils'
+import { getAdminUsersSort, throwIfNotValid } from '../shared'
import { VideoModel } from '../video/video'
import { VideoChannelModel } from '../video/video-channel'
import { VideoImportModel } from '../video/video-import'
import { VideoLiveModel } from '../video/video-live'
import { VideoPlaylistModel } from '../video/video-playlist'
import { UserNotificationSettingModel } from './user-notification-setting'
-import { forceNumber } from '@shared/core-utils'
enum ScopeNames {
FOR_ME_API = 'FOR_ME_API',
})
OAuthTokens: OAuthTokenModel[]
+ // Used if we already set an encrypted password in user model
+ skipPasswordEncryption = false
+
@BeforeCreate
@BeforeUpdate
- static cryptPasswordIfNeeded (instance: UserModel) {
- if (instance.changed('password') && instance.password) {
- return cryptPassword(instance.password)
- .then(hash => {
- instance.password = hash
- return undefined
- })
- }
+ static async cryptPasswordIfNeeded (instance: UserModel) {
+ if (instance.skipPasswordEncryption) return
+ if (!instance.changed('password')) return
+ if (!instance.password) return
+
+ instance.password = await cryptPassword(instance.password)
}
@AfterUpdate
+++ /dev/null
-import { literal, Op, OrderItem, Sequelize } from 'sequelize'
-import validator from 'validator'
-import { forceNumber } from '@shared/core-utils'
-
-type SortType = { sortModel: string, sortValue: string }
-
-// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
-function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
-
- let finalField: string | ReturnType<typeof Sequelize.col>
-
- if (field.toLowerCase() === 'match') { // Search
- finalField = Sequelize.col('similarity')
- } else {
- finalField = field
- }
-
- return [ [ finalField, direction ], lastSort ]
-}
-
-function getAdminUsersSort (value: string): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
-
- let finalField: string | ReturnType<typeof Sequelize.col>
-
- if (field === 'videoQuotaUsed') { // Users list
- finalField = Sequelize.col('videoQuotaUsed')
- } else {
- finalField = field
- }
-
- const nullPolicy = direction === 'ASC'
- ? 'NULLS FIRST'
- : 'NULLS LAST'
-
- // FIXME: typings
- return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
-}
-
-function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
-
- if (field.toLowerCase() === 'name') {
- return [ [ 'displayName', direction ], lastSort ]
- }
-
- return getSort(value, lastSort)
-}
-
-function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
-
- if (field === 'totalReplies') {
- return [
- [ Sequelize.literal('"totalReplies"'), direction ],
- lastSort
- ]
- }
-
- return getSort(value, lastSort)
-}
-
-function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
-
- if (field.toLowerCase() === 'trending') { // Sort by aggregation
- return [
- [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
-
- [ Sequelize.col('VideoModel.views'), direction ],
-
- lastSort
- ]
- } else if (field === 'publishedAt') {
- return [
- [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
-
- [ Sequelize.col('VideoModel.publishedAt'), direction ],
-
- lastSort
- ]
- }
-
- let finalField: string | ReturnType<typeof Sequelize.col>
-
- // Alias
- if (field.toLowerCase() === 'match') { // Search
- finalField = Sequelize.col('similarity')
- } else {
- finalField = field
- }
-
- const firstSort: OrderItem = typeof finalField === 'string'
- ? finalField.split('.').concat([ direction ]) as OrderItem
- : [ finalField, direction ]
-
- return [ firstSort, lastSort ]
-}
-
-function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
- const [ firstSort ] = getSort(value)
-
- if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
- return [ firstSort, lastSort ]
-}
-
-function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
-
- if (field === 'redundancyAllowed') {
- return [
- [ 'ActorFollowing.Server.redundancyAllowed', direction ],
- lastSort
- ]
- }
-
- return getSort(value, lastSort)
-}
-
-function getChannelSyncSort (value: string): OrderItem[] {
- const { direction, field } = buildDirectionAndField(value)
- if (field.toLowerCase() === 'videochannel') {
- return [
- [ literal('"VideoChannel.name"'), direction ]
- ]
- }
- return [ [ field, direction ] ]
-}
-
-function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
- if (!model.createdAt || !model.updatedAt) {
- throw new Error('Miss createdAt & updatedAt attributes to model')
- }
-
- const now = Date.now()
- const createdAtTime = model.createdAt.getTime()
- const updatedAtTime = model.updatedAt.getTime()
-
- return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
-}
-
-function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
- if (nullable && (value === null || value === undefined)) return
-
- if (validator(value) === false) {
- throw new Error(`"${value}" is not a valid ${fieldName}.`)
- }
-}
-
-function buildTrigramSearchIndex (indexName: string, attribute: string) {
- return {
- name: indexName,
- // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
- fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
- using: 'gin',
- operator: 'gin_trgm_ops'
- }
-}
-
-function createSimilarityAttribute (col: string, value: string) {
- return Sequelize.fn(
- 'similarity',
-
- searchTrigramNormalizeCol(col),
-
- searchTrigramNormalizeValue(value)
- )
-}
-
-function buildBlockedAccountSQL (blockerIds: number[]) {
- const blockerIdsString = blockerIds.join(', ')
-
- return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
- ' UNION ' +
- 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
- 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
- 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
-}
-
-function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
- const blockerIdsString = blockerIds.join(', ')
-
- return [
- literal(
- `NOT EXISTS (` +
- ` SELECT 1 FROM "accountBlocklist" ` +
- ` WHERE "targetAccountId" = ${columnNameJoin} ` +
- ` AND "accountId" IN (${blockerIdsString})` +
- `)`
- ),
-
- literal(
- `NOT EXISTS (` +
- ` SELECT 1 FROM "account" ` +
- ` INNER JOIN "actor" ON account."actorId" = actor.id ` +
- ` INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
- ` WHERE "account"."id" = ${columnNameJoin} ` +
- ` AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
- `)`
- )
- ]
-}
-
-function buildServerIdsFollowedBy (actorId: any) {
- const actorIdNumber = forceNumber(actorId)
-
- return '(' +
- 'SELECT "actor"."serverId" FROM "actorFollow" ' +
- 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
- 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
- ')'
-}
-
-function buildWhereIdOrUUID (id: number | string) {
- return validator.isInt('' + id) ? { id } : { uuid: id }
-}
-
-function parseAggregateResult (result: any) {
- if (!result) return 0
-
- const total = forceNumber(result)
- if (isNaN(total)) return 0
-
- return total
-}
-
-function parseRowCountResult (result: any) {
- if (result.length !== 0) return result[0].total
-
- return 0
-}
-
-function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
- return stringArr.map(t => {
- return t === null
- ? null
- : sequelize.escape('' + t)
- }).join(', ')
-}
-
-function buildLocalAccountIdsIn () {
- return literal(
- '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
- )
-}
-
-function buildLocalActorIdsIn () {
- return literal(
- '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
- )
-}
-
-function buildDirectionAndField (value: string) {
- let field: string
- let direction: 'ASC' | 'DESC'
-
- if (value.substring(0, 1) === '-') {
- direction = 'DESC'
- field = value.substring(1)
- } else {
- direction = 'ASC'
- field = value
- }
-
- return { direction, field }
-}
-
-function searchAttribute (sourceField?: string, targetField?: string) {
- if (!sourceField) return {}
-
- return {
- [targetField]: {
- // FIXME: ts error
- [Op.iLike as any]: `%${sourceField}%`
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-
-export {
- buildBlockedAccountSQL,
- buildBlockedAccountSQLOptimized,
- buildLocalActorIdsIn,
- getPlaylistSort,
- SortType,
- buildLocalAccountIdsIn,
- getSort,
- getCommentSort,
- getAdminUsersSort,
- getVideoSort,
- getBlacklistSort,
- getChannelSyncSort,
- createSimilarityAttribute,
- throwIfNotValid,
- buildServerIdsFollowedBy,
- buildTrigramSearchIndex,
- buildWhereIdOrUUID,
- isOutdated,
- parseAggregateResult,
- getInstanceFollowsSort,
- buildDirectionAndField,
- createSafeIn,
- searchAttribute,
- parseRowCountResult
-}
-
-// ---------------------------------------------------------------------------
-
-function searchTrigramNormalizeValue (value: string) {
- return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
-}
-
-function searchTrigramNormalizeCol (col: string) {
- return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
-}
}
function getCategoryLabel (id: number) {
- return VIDEO_CATEGORIES[id] || 'Misc'
+ return VIDEO_CATEGORIES[id] || 'Unknown'
}
function getLicenceLabel (id: number) {
--- /dev/null
+import { Model, Sequelize, Transaction } from 'sequelize'
+import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
+import { ActorImageType, VideoPrivacy } from '@shared/models'
+import { createSafeIn, getSort, parseRowCountResult } from '../../../shared'
+import { VideoCommentTableAttributes } from './video-comment-table-attributes'
+
+export interface ListVideoCommentsOptions {
+ selectType: 'api' | 'feed' | 'comment-only'
+
+ start?: number
+ count?: number
+ sort?: string
+
+ videoId?: number
+ threadId?: number
+ accountId?: number
+ videoChannelId?: number
+
+ blockerAccountIds?: number[]
+
+ isThread?: boolean
+ notDeleted?: boolean
+ isLocal?: boolean
+ onLocalVideo?: boolean
+ onPublicVideo?: boolean
+ videoAccountOwnerId?: boolean
+
+ search?: string
+ searchAccount?: string
+ searchVideo?: string
+
+ includeReplyCounters?: boolean
+
+ transaction?: Transaction
+}
+
+export class VideoCommentListQueryBuilder extends AbstractRunQuery {
+ private readonly tableAttributes = new VideoCommentTableAttributes()
+
+ private innerQuery: string
+
+ private select = ''
+ private joins = ''
+
+ private innerSelect = ''
+ private innerJoins = ''
+ private innerLateralJoins = ''
+ private innerWhere = ''
+
+ private readonly built = {
+ cte: false,
+ accountJoin: false,
+ videoJoin: false,
+ videoChannelJoin: false,
+ avatarJoin: false
+ }
+
+ constructor (
+ protected readonly sequelize: Sequelize,
+ private readonly options: ListVideoCommentsOptions
+ ) {
+ super(sequelize)
+
+ if (this.options.includeReplyCounters && !this.options.videoId) {
+ throw new Error('Cannot include reply counters without videoId')
+ }
+ }
+
+ async listComments <T extends Model> () {
+ this.buildListQuery()
+
+ const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
+ const modelBuilder = new ModelBuilder<T>(this.sequelize)
+
+ return modelBuilder.createModels(results, 'VideoComment')
+ }
+
+ async countComments () {
+ this.buildCountQuery()
+
+ const result = await this.runQuery({ transaction: this.options.transaction })
+
+ return parseRowCountResult(result)
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private buildListQuery () {
+ this.buildInnerListQuery()
+ this.buildListSelect()
+
+ this.query = `${this.select} ` +
+ `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
+ `${this.joins} ` +
+ `${this.getOrder()}`
+ }
+
+ private buildInnerListQuery () {
+ this.buildWhere()
+ this.buildInnerListSelect()
+
+ this.innerQuery = `${this.innerSelect} ` +
+ `FROM "videoComment" AS "VideoCommentModel" ` +
+ `${this.innerJoins} ` +
+ `${this.innerLateralJoins} ` +
+ `${this.innerWhere} ` +
+ `${this.getOrder()} ` +
+ `${this.getInnerLimit()}`
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private buildCountQuery () {
+ this.buildWhere()
+
+ this.query = `SELECT COUNT(*) AS "total" ` +
+ `FROM "videoComment" AS "VideoCommentModel" ` +
+ `${this.innerJoins} ` +
+ `${this.innerWhere}`
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private buildWhere () {
+ let where: string[] = []
+
+ if (this.options.videoId) {
+ this.replacements.videoId = this.options.videoId
+
+ where.push('"VideoCommentModel"."videoId" = :videoId')
+ }
+
+ if (this.options.threadId) {
+ this.replacements.threadId = this.options.threadId
+
+ where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
+ }
+
+ if (this.options.accountId) {
+ this.replacements.accountId = this.options.accountId
+
+ where.push('"VideoCommentModel"."accountId" = :accountId')
+ }
+
+ if (this.options.videoChannelId) {
+ this.buildVideoChannelJoin()
+
+ this.replacements.videoChannelId = this.options.videoChannelId
+
+ where.push('"Account->VideoChannel"."id" = :videoChannelId')
+ }
+
+ if (this.options.blockerAccountIds) {
+ this.buildVideoChannelJoin()
+
+ where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
+ }
+
+ if (this.options.isThread === true) {
+ where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
+ }
+
+ if (this.options.notDeleted === true) {
+ where.push('"VideoCommentModel"."deletedAt" IS NULL')
+ }
+
+ if (this.options.isLocal === true) {
+ this.buildAccountJoin()
+
+ where.push('"Account->Actor"."serverId" IS NULL')
+ } else if (this.options.isLocal === false) {
+ this.buildAccountJoin()
+
+ where.push('"Account->Actor"."serverId" IS NOT NULL')
+ }
+
+ if (this.options.onLocalVideo === true) {
+ this.buildVideoJoin()
+
+ where.push('"Video"."remote" IS FALSE')
+ } else if (this.options.onLocalVideo === false) {
+ this.buildVideoJoin()
+
+ where.push('"Video"."remote" IS TRUE')
+ }
+
+ if (this.options.onPublicVideo === true) {
+ this.buildVideoJoin()
+
+ where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
+ }
+
+ if (this.options.videoAccountOwnerId) {
+ this.buildVideoChannelJoin()
+
+ this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
+
+ where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
+ }
+
+ if (this.options.search) {
+ this.buildVideoJoin()
+ this.buildAccountJoin()
+
+ const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
+
+ where.push(
+ `(` +
+ `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
+ `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
+ `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
+ `"Video"."name" ILIKE ${escapedLikeSearch} ` +
+ `)`
+ )
+ }
+
+ if (this.options.searchAccount) {
+ this.buildAccountJoin()
+
+ const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
+
+ where.push(
+ `(` +
+ `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
+ `"Account"."name" ILIKE ${escapedLikeSearch} ` +
+ `)`
+ )
+ }
+
+ if (this.options.searchVideo) {
+ this.buildVideoJoin()
+
+ const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
+
+ where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
+ }
+
+ if (where.length !== 0) {
+ this.innerWhere = `WHERE ${where.join(' AND ')}`
+ }
+ }
+
+ private buildAccountJoin () {
+ if (this.built.accountJoin) return
+
+ this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
+ 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
+ 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
+
+ this.built.accountJoin = true
+ }
+
+ private buildVideoJoin () {
+ if (this.built.videoJoin) return
+
+ this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
+
+ this.built.videoJoin = true
+ }
+
+ private buildVideoChannelJoin () {
+ if (this.built.videoChannelJoin) return
+
+ this.buildVideoJoin()
+
+ this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
+
+ this.built.videoChannelJoin = true
+ }
+
+ private buildAvatarsJoin () {
+ if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
+ if (this.built.avatarJoin) return
+
+ this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
+ `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
+ `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
+
+ this.built.avatarJoin = true
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private buildListSelect () {
+ const toSelect = [ '"VideoCommentModel".*' ]
+
+ if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
+ this.buildAvatarsJoin()
+
+ toSelect.push(this.tableAttributes.getAvatarAttributes())
+ }
+
+ this.select = this.buildSelect(toSelect)
+ }
+
+ private buildInnerListSelect () {
+ let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
+
+ if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
+ this.buildAccountJoin()
+ this.buildVideoJoin()
+
+ toSelect = toSelect.concat([
+ this.tableAttributes.getVideoAttributes(),
+ this.tableAttributes.getAccountAttributes(),
+ this.tableAttributes.getActorAttributes(),
+ this.tableAttributes.getServerAttributes()
+ ])
+ }
+
+ if (this.options.includeReplyCounters === true) {
+ this.buildTotalRepliesSelect()
+ this.buildAuthorTotalRepliesSelect()
+
+ toSelect.push('"totalRepliesFromVideoAuthor"."count" AS "totalRepliesFromVideoAuthor"')
+ toSelect.push('"totalReplies"."count" AS "totalReplies"')
+ }
+
+ this.innerSelect = this.buildSelect(toSelect)
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private getBlockWhere (commentTableName: string, channelTableName: string) {
+ const where: string[] = []
+
+ const blockerIdsString = createSafeIn(
+ this.sequelize,
+ this.options.blockerAccountIds,
+ [ `"${channelTableName}"."accountId"` ]
+ )
+
+ where.push(
+ `NOT EXISTS (` +
+ `SELECT 1 FROM "accountBlocklist" ` +
+ `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
+ `AND "accountId" IN (${blockerIdsString})` +
+ `)`
+ )
+
+ where.push(
+ `NOT EXISTS (` +
+ `SELECT 1 FROM "account" ` +
+ `INNER JOIN "actor" ON account."actorId" = actor.id ` +
+ `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
+ `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
+ `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
+ `)`
+ )
+
+ return where
+ }
+
+ // ---------------------------------------------------------------------------
+
+ private buildTotalRepliesSelect () {
+ const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
+
+ // Help the planner by providing videoId that should filter out many comments
+ this.replacements.videoId = this.options.videoId
+
+ this.innerLateralJoins += `LEFT JOIN LATERAL (` +
+ `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
+ `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
+ `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
+ `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
+ `AND "deletedAt" IS NULL ` +
+ `AND ${blockWhereString} ` +
+ `) "totalReplies" ON TRUE `
+ }
+
+ private buildAuthorTotalRepliesSelect () {
+ // Help the planner by providing videoId that should filter out many comments
+ this.replacements.videoId = this.options.videoId
+
+ this.innerLateralJoins += `LEFT JOIN LATERAL (` +
+ `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
+ `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
+ `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
+ `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
+ `) "totalRepliesFromVideoAuthor" ON TRUE `
+ }
+
+ private getOrder () {
+ if (!this.options.sort) return ''
+
+ const orders = getSort(this.options.sort)
+
+ return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
+ }
+
+ private getInnerLimit () {
+ if (!this.options.count) return ''
+
+ this.replacements.limit = this.options.count
+ this.replacements.offset = this.options.start || 0
+
+ return `LIMIT :limit OFFSET :offset `
+ }
+}
--- /dev/null
+import { Memoize } from '@server/helpers/memoize'
+import { AccountModel } from '@server/models/account/account'
+import { ActorModel } from '@server/models/actor/actor'
+import { ActorImageModel } from '@server/models/actor/actor-image'
+import { ServerModel } from '@server/models/server/server'
+import { VideoCommentModel } from '../../video-comment'
+
+export class VideoCommentTableAttributes {
+
+ @Memoize()
+ getVideoCommentAttributes () {
+ return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
+ }
+
+ @Memoize()
+ getAccountAttributes () {
+ return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
+ }
+
+ @Memoize()
+ getVideoAttributes () {
+ return [
+ `"Video"."id" AS "Video.id"`,
+ `"Video"."uuid" AS "Video.uuid"`,
+ `"Video"."name" AS "Video.name"`
+ ].join(', ')
+ }
+
+ @Memoize()
+ getActorAttributes () {
+ return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
+ }
+
+ @Memoize()
+ getServerAttributes () {
+ return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
+ }
+
+ @Memoize()
+ getAvatarAttributes () {
+ return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
+ }
+}
import { Sequelize } from 'sequelize'
import validator from 'validator'
-import { createSafeIn } from '@server/models/utils'
import { MUserAccountId } from '@server/types/models'
import { ActorImageType } from '@shared/models'
import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
+import { createSafeIn } from '../../../../shared'
import { VideoTableAttributes } from './video-table-attributes'
/**
import validator from 'validator'
import { exists } from '@server/helpers/custom-validators/misc'
import { WEBSERVER } from '@server/initializers/constants'
-import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils'
+import { buildSortDirectionAndField } from '@server/models/shared'
import { MUserAccountId, MUserId } from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
+import { createSafeIn, parseRowCountResult } from '../../../shared'
import { AbstractRunQuery } from '../../../shared/abstract-run-query'
-import { forceNumber } from '@shared/core-utils'
/**
*
}
private buildOrder (value: string) {
- const { direction, field } = buildDirectionAndField(value)
+ const { direction, field } = buildSortDirectionAndField(value)
if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
import { AttributesOnly } from '@shared/typescript-utils'
import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
import { isVideoTagValid } from '../../helpers/custom-validators/videos'
-import { throwIfNotValid } from '../utils'
+import { throwIfNotValid } from '../shared'
import { VideoModel } from './video'
import { VideoTagModel } from './video-tag'
import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
-import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils'
+import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
import { ThumbnailModel } from './thumbnail'
import { VideoModel } from './video'
import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
static listForApi (parameters: {
start: number
count: number
- sort: SortType
+ sort: string
search?: string
type?: VideoBlacklistType
}) {
return {
offset: start,
limit: count,
- order: getBlacklistSort(sort.sortModel, sort.sortValue)
+ order: getBlacklistSort(sort)
}
}
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
-import { buildWhereIdOrUUID, throwIfNotValid } from '../utils'
+import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
import { VideoModel } from './video'
export enum ScopeNames {
import { AttributesOnly } from '@shared/typescript-utils'
import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
import { AccountModel } from '../account/account'
-import { getSort } from '../utils'
+import { getSort } from '../shared'
import { ScopeNames as VideoScopeNames, VideoModel } from './video'
enum ScopeNames {
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountModel } from '../account/account'
import { UserModel } from '../user/user'
-import { getChannelSyncSort, throwIfNotValid } from '../utils'
+import { getChannelSyncSort, throwIfNotValid } from '../shared'
import { VideoChannelModel } from './video-channel'
@DefaultScope(() => ({
import { ActorFollowModel } from '../actor/actor-follow'
import { ActorImageModel } from '../actor/actor-image'
import { ServerModel } from '../server/server'
-import { setAsUpdated } from '../shared'
-import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
+import {
+ buildServerIdsFollowedBy,
+ buildTrigramSearchIndex,
+ createSimilarityAttribute,
+ getSort,
+ setAsUpdated,
+ throwIfNotValid
+} from '../shared'
import { VideoModel } from './video'
import { VideoPlaylistModel } from './video-playlist'
}
setAsUpdated (transaction?: Transaction) {
- return setAsUpdated('videoChannel', this.id, transaction)
+ return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
}
}
-import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
+import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
import {
AllowNull,
BelongsTo,
Table,
UpdatedAt
} from 'sequelize-typescript'
-import { exists } from '@server/helpers/custom-validators/misc'
import { getServerActor } from '@server/models/application/application'
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
-import { uniqify } from '@shared/core-utils'
-import { VideoPrivacy } from '@shared/models'
+import { pick, uniqify } from '@shared/core-utils'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
} from '../../types/models/video'
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
import { AccountModel } from '../account/account'
-import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
-import {
- buildBlockedAccountSQL,
- buildBlockedAccountSQLOptimized,
- buildLocalAccountIdsIn,
- getCommentSort,
- searchAttribute,
- throwIfNotValid
-} from '../utils'
+import { ActorModel } from '../actor/actor'
+import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
+import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
import { VideoModel } from './video'
import { VideoChannelModel } from './video-channel'
export enum ScopeNames {
WITH_ACCOUNT = 'WITH_ACCOUNT',
- WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
- WITH_VIDEO = 'WITH_VIDEO',
- ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
+ WITH_VIDEO = 'WITH_VIDEO'
}
@Scopes(() => ({
- [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
- return {
- attributes: {
- include: [
- [
- Sequelize.literal(
- '(' +
- 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
- 'SELECT COUNT("replies"."id") ' +
- 'FROM "videoComment" AS "replies" ' +
- 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
- 'AND "deletedAt" IS NULL ' +
- 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
- ')'
- ),
- 'totalReplies'
- ],
- [
- Sequelize.literal(
- '(' +
- 'SELECT COUNT("replies"."id") ' +
- 'FROM "videoComment" AS "replies" ' +
- 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
- 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
- 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
- 'AND "replies"."accountId" = "videoChannel"."accountId"' +
- ')'
- ),
- 'totalRepliesFromVideoAuthor'
- ]
- ]
- }
- } as FindOptions
- },
[ScopeNames.WITH_ACCOUNT]: {
include: [
{
}
]
},
- [ScopeNames.WITH_ACCOUNT_FOR_API]: {
- include: [
- {
- model: AccountModel.unscoped(),
- include: [
- {
- attributes: {
- exclude: unusedActorAttributesForAPI
- },
- model: ActorModel, // Default scope includes avatar and server
- required: true
- }
- ]
- }
- ]
- },
[ScopeNames.WITH_IN_REPLY_TO]: {
include: [
{
})
CommentAbuses: VideoCommentAbuseModel[]
+ // ---------------------------------------------------------------------------
+
+ static getSQLAttributes (tableName: string, aliasPrefix = '') {
+ return buildSQLAttributes({
+ model: this,
+ tableName,
+ aliasPrefix
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
static loadById (id: number, t?: Transaction): Promise<MComment> {
const query: FindOptions = {
where: {
searchAccount?: string
searchVideo?: string
}) {
- const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters
+ const queryOptions: ListVideoCommentsOptions = {
+ ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
- const where: WhereOptions = {
- deletedAt: null
- }
-
- const whereAccount: WhereOptions = {}
- const whereActor: WhereOptions = {}
- const whereVideo: WhereOptions = {}
-
- if (isLocal === true) {
- Object.assign(whereActor, {
- serverId: null
- })
- } else if (isLocal === false) {
- Object.assign(whereActor, {
- serverId: {
- [Op.ne]: null
- }
- })
- }
-
- if (search) {
- Object.assign(where, {
- [Op.or]: [
- searchAttribute(search, 'text'),
- searchAttribute(search, '$Account.Actor.preferredUsername$'),
- searchAttribute(search, '$Account.name$'),
- searchAttribute(search, '$Video.name$')
- ]
- })
- }
-
- if (searchAccount) {
- Object.assign(whereActor, {
- [Op.or]: [
- searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
- searchAttribute(searchAccount, '$Account.name$')
- ]
- })
- }
-
- if (searchVideo) {
- Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
- }
-
- if (exists(onLocalVideo)) {
- Object.assign(whereVideo, { remote: !onLocalVideo })
- }
-
- const getQuery = (forCount: boolean) => {
- return {
- offset: start,
- limit: count,
- order: getCommentSort(sort),
- where,
- include: [
- {
- model: AccountModel.unscoped(),
- required: true,
- where: whereAccount,
- include: [
- {
- attributes: {
- exclude: unusedActorAttributesForAPI
- },
- model: forCount === true
- ? ActorModel.unscoped() // Default scope includes avatar and server
- : ActorModel,
- required: true,
- where: whereActor
- }
- ]
- },
- {
- model: VideoModel.unscoped(),
- required: true,
- where: whereVideo
- }
- ]
- }
+ selectType: 'api',
+ notDeleted: true
}
return Promise.all([
- VideoCommentModel.count(getQuery(true)),
- VideoCommentModel.findAll(getQuery(false))
- ]).then(([ total, data ]) => ({ total, data }))
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+ ]).then(([ rows, count ]) => {
+ return { total: count, data: rows }
+ })
}
static async listThreadsForApi (parameters: {
sort: string
user?: MUserAccountId
}) {
- const { videoId, isVideoOwned, start, count, sort, user } = parameters
+ const { videoId, user } = parameters
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
+ const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
- const accountBlockedWhere = {
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
- )
- }
+ const commonOptions: ListVideoCommentsOptions = {
+ selectType: 'api',
+ videoId,
+ blockerAccountIds
}
- const queryList = {
- offset: start,
- limit: count,
- order: getCommentSort(sort),
- where: {
- [Op.and]: [
- {
- videoId
- },
- {
- inReplyToCommentId: null
- },
- {
- [Op.or]: [
- accountBlockedWhere,
- {
- accountId: null
- }
- ]
- }
- ]
- }
+ const listOptions: ListVideoCommentsOptions = {
+ ...commonOptions,
+ ...pick(parameters, [ 'sort', 'start', 'count' ]),
+
+ isThread: true,
+ includeReplyCounters: true
}
- const findScopesList: (string | ScopeOptions)[] = [
- ScopeNames.WITH_ACCOUNT_FOR_API,
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
- }
- ]
+ const countOptions: ListVideoCommentsOptions = {
+ ...commonOptions,
- const countScopesList: ScopeOptions[] = [
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
- }
- ]
+ isThread: true
+ }
- const notDeletedQueryCount = {
- where: {
- videoId,
- deletedAt: null,
- ...accountBlockedWhere
- }
+ const notDeletedCountOptions: ListVideoCommentsOptions = {
+ ...commonOptions,
+
+ notDeleted: true
}
return Promise.all([
- VideoCommentModel.scope(findScopesList).findAll(queryList),
- VideoCommentModel.scope(countScopesList).count(queryList),
- VideoCommentModel.count(notDeletedQueryCount)
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
]).then(([ rows, count, totalNotDeletedComments ]) => {
return { total: count, data: rows, totalNotDeletedComments }
})
static async listThreadCommentsForApi (parameters: {
videoId: number
- isVideoOwned: boolean
threadId: number
user?: MUserAccountId
}) {
- const { videoId, threadId, user, isVideoOwned } = parameters
+ const { user } = parameters
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
+ const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
- const query = {
- order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
- where: {
- videoId,
- [Op.and]: [
- {
- [Op.or]: [
- { id: threadId },
- { originCommentId: threadId }
- ]
- },
- {
- [Op.or]: [
- {
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
- )
- }
- },
- {
- accountId: null
- }
- ]
- }
- ]
- }
- }
+ const queryOptions: ListVideoCommentsOptions = {
+ ...pick(parameters, [ 'videoId', 'threadId' ]),
- const scopes: any[] = [
- ScopeNames.WITH_ACCOUNT_FOR_API,
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
- }
- ]
+ selectType: 'api',
+ sort: 'createdAt',
+
+ blockerAccountIds,
+ includeReplyCounters: true
+ }
return Promise.all([
- VideoCommentModel.count(query),
- VideoCommentModel.scope(scopes).findAll(query)
- ]).then(([ total, data ]) => ({ total, data }))
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+ ]).then(([ rows, count ]) => {
+ return { total: count, data: rows }
+ })
}
static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
.findAll(query)
}
- static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
+ static async listAndCountByVideoForAP (parameters: {
+ video: MVideoImmutable
+ start: number
+ count: number
+ }) {
+ const { video } = parameters
+
+ const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
+
+ const queryOptions: ListVideoCommentsOptions = {
+ ...pick(parameters, [ 'start', 'count' ]),
+
+ selectType: 'comment-only',
videoId: video.id,
- isVideoOwned: video.isOwned()
- })
+ sort: 'createdAt',
- const query = {
- order: [ [ 'createdAt', 'ASC' ] ] as Order,
- offset: start,
- limit: count,
- where: {
- videoId: video.id,
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
- )
- }
- },
- transaction: t
+ blockerAccountIds
}
return Promise.all([
- VideoCommentModel.count(query),
- VideoCommentModel.findAll<MComment>(query)
- ]).then(([ total, data ]) => ({ total, data }))
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
+ new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
+ ]).then(([ rows, count ]) => {
+ return { total: count, data: rows }
+ })
}
static async listForFeed (parameters: {
videoId?: number
accountId?: number
videoChannelId?: number
- }): Promise<MCommentOwnerVideoFeed[]> {
- const serverActor = await getServerActor()
- const { start, count, videoId, accountId, videoChannelId } = parameters
-
- const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
- '"VideoCommentModel"."accountId"',
- [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
- )
+ }) {
+ const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
- if (accountId) {
- whereAnd.push({
- accountId
- })
- }
+ const queryOptions: ListVideoCommentsOptions = {
+ ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
- const accountWhere = {
- [Op.and]: whereAnd
- }
+ selectType: 'feed',
- const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
+ sort: '-createdAt',
+ onPublicVideo: true,
+ notDeleted: true,
- const query = {
- order: [ [ 'createdAt', 'DESC' ] ] as Order,
- offset: start,
- limit: count,
- where: {
- deletedAt: null,
- accountId: accountWhere
- },
- include: [
- {
- attributes: [ 'name', 'uuid' ],
- model: VideoModel.unscoped(),
- required: true,
- where: {
- privacy: VideoPrivacy.PUBLIC
- },
- include: [
- {
- attributes: [ 'accountId' ],
- model: VideoChannelModel.unscoped(),
- required: true,
- where: videoChannelWhere
- }
- ]
- }
- ]
+ blockerAccountIds
}
- if (videoId) query.where['videoId'] = videoId
-
- return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
- .findAll(query)
+ return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
}
static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
- const accountWhere = filter.onVideosOfAccount
- ? { id: filter.onVideosOfAccount.id }
- : {}
+ const queryOptions: ListVideoCommentsOptions = {
+ selectType: 'comment-only',
- const query = {
- limit: 1000,
- where: {
- deletedAt: null,
- accountId: ofAccount.id
- },
- include: [
- {
- model: VideoModel,
- required: true,
- include: [
- {
- model: VideoChannelModel,
- required: true,
- include: [
- {
- model: AccountModel,
- required: true,
- where: accountWhere
- }
- ]
- }
- ]
- }
- ]
+ accountId: ofAccount.id,
+ videoAccountOwnerId: filter.onVideosOfAccount?.id,
+
+ notDeleted: true,
+ count: 5000
}
- return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
- .findAll(query)
+ return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
}
static async getStats () {
}
isOwned () {
- if (!this.Account) {
- return false
- }
+ if (!this.Account) return false
return this.Account.isOwned()
}
}
private static async buildBlockerAccountIds (options: {
- videoId: number
- isVideoOwned: boolean
- user?: MUserAccountId
- }) {
- const { videoId, user, isVideoOwned } = options
+ user: MUserAccountId
+ }): Promise<number[]> {
+ const { user } = options
const serverActor = await getServerActor()
const blockerAccountIds = [ serverActor.Account.id ]
if (user) blockerAccountIds.push(user.Account.id)
- if (isVideoOwned) {
- const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
- if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
- }
-
return blockerAccountIds
}
}
import validator from 'validator'
import { logger } from '@server/helpers/logger'
import { extractVideo } from '@server/helpers/video'
+import { CONFIG } from '@server/initializers/config'
import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
import {
getHLSPrivateFileUrl,
} from '../../initializers/constants'
import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import { doesExist } from '../shared'
-import { parseAggregateResult, throwIfNotValid } from '../utils'
+import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
import { VideoModel } from './video'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
-import { CONFIG } from '@server/initializers/config'
export enum ScopeNames {
WITH_VIDEO = 'WITH_VIDEO',
static doesInfohashExist (infoHash: string) {
const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
- return doesExist(query, { infoHash })
+ return doesExist(this.sequelize, query, { infoHash })
}
static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
- return doesExist(query, { filename })
+ return doesExist(this.sequelize, query, { filename })
}
static async doesOwnedWebTorrentVideoFileExist (filename: string) {
const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
`WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
- return doesExist(query, { filename })
+ return doesExist(this.sequelize, query, { filename })
}
static loadByFilename (filename: string) {
if (!element) return videoFile.save({ transaction })
for (const k of Object.keys(videoFile.toJSON())) {
- element[k] = videoFile[k]
+ element.set(k, videoFile[k])
}
return element.save({ transaction })
import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
import { UserModel } from '../user/user'
-import { getSort, searchAttribute, throwIfNotValid } from '../utils'
+import { getSort, searchAttribute, throwIfNotValid } from '../shared'
import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
import { VideoChannelSyncModel } from './video-channel-sync'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { AccountModel } from '../account/account'
-import { getSort, throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../shared'
import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
import { VideoPlaylistModel } from './video-playlist'
return VideoPlaylistElementModel.increment({ position: by }, query)
}
- getType (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) {
+ toFormattedJSON (
+ this: MVideoPlaylistElementFormattable,
+ options: { accountId?: number } = {}
+ ): VideoPlaylistElement {
+ return {
+ id: this.id,
+ position: this.position,
+ startTimestamp: this.startTimestamp,
+ stopTimestamp: this.stopTimestamp,
+
+ type: this.getType(options.accountId),
+
+ video: this.getVideoElement(options.accountId)
+ }
+ }
+
+ getType (this: MVideoPlaylistElementFormattable, accountId?: number) {
const video = this.Video
if (!video) return VideoPlaylistElementType.DELETED
if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE
if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
- if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE
return VideoPlaylistElementType.REGULAR
}
- getVideoElement (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) {
+ getVideoElement (this: MVideoPlaylistElementFormattable, accountId?: number) {
if (!this.Video) return null
- if (this.getType(displayNSFW, accountId) !== VideoPlaylistElementType.REGULAR) return null
+ if (this.getType(accountId) !== VideoPlaylistElementType.REGULAR) return null
return this.Video.toFormattedJSON()
}
- toFormattedJSON (
- this: MVideoPlaylistElementFormattable,
- options: { displayNSFW?: boolean, accountId?: number } = {}
- ): VideoPlaylistElement {
- return {
- id: this.id,
- position: this.position,
- startTimestamp: this.startTimestamp,
- stopTimestamp: this.stopTimestamp,
-
- type: this.getType(options.displayNSFW, options.accountId),
-
- video: this.getVideoElement(options.displayNSFW, options.accountId)
- }
- }
-
toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject {
const base: PlaylistElementObject = {
id: this.url,
import { MAccountId, MChannelId } from '@server/types/models'
import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
import { buildUUID, uuidToShort } from '@shared/extra-utils'
+import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import {
isVideoPlaylistDescriptionValid,
} from '../../types/models/video/video-playlist'
import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
import { ActorModel } from '../actor/actor'
-import { setAsUpdated } from '../shared'
import {
buildServerIdsFollowedBy,
buildTrigramSearchIndex,
createSimilarityAttribute,
getPlaylistSort,
isOutdated,
+ setAsUpdated,
throwIfNotValid
-} from '../utils'
+} from '../shared'
import { ThumbnailModel } from './thumbnail'
import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
import { VideoPlaylistElementModel } from './video-playlist-element'
}
setAsRefreshed () {
- return setAsUpdated('videoPlaylist', this.id)
+ return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
}
setVideosLength (videosLength: number) {
import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
import { ActorModel } from '../actor/actor'
-import { buildLocalActorIdsIn, throwIfNotValid } from '../utils'
+import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
import { VideoModel } from './video'
enum ScopeNames {
WEBSERVER
} from '../../initializers/constants'
import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import { doesExist } from '../shared'
-import { throwIfNotValid } from '../utils'
+import { doesExist, throwIfNotValid } from '../shared'
import { VideoModel } from './video'
@Table({
static doesInfohashExist (infoHash: string) {
const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
- return doesExist(query, { infoHash })
+ return doesExist(this.sequelize, query, { infoHash })
}
static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
`AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
`AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
- return doesExist(query, { videoUUID })
+ return doesExist(this.sequelize, query, { videoUUID })
}
assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
import { VideoPathManager } from '@server/lib/video-path-manager'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
import { getServerActor } from '@server/models/application/application'
-import { ModelCache } from '@server/models/model-cache'
+import { ModelCache } from '@server/models/shared/model-cache'
import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
import {
import { ServerModel } from '../server/server'
import { TrackerModel } from '../server/tracker'
import { VideoTrackerModel } from '../server/video-tracker'
-import { setAsUpdated } from '../shared'
+import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
import { UserModel } from '../user/user'
import { UserVideoHistoryModel } from '../user/user-video-history'
-import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
import { VideoViewModel } from '../view/video-view'
import {
videoFilesModelToFormattedJSON,
}
setAsRefreshed (transaction?: Transaction) {
- return setAsUpdated('video', this.id, transaction)
+ return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
}
// ---------------------------------------------------------------------------
indexes: [
{
fields: [ 'videoId' ]
+ },
+ {
+ fields: [ 'url' ],
+ unique: true
}
]
})
it('Should destroy server 3 internal shares and correctly clean them', async function () {
this.timeout(20000)
- const preCount = await servers[0].sql.getCount('videoShare')
+ const preCount = await servers[0].sql.getVideoShareCount()
expect(preCount).to.equal(6)
await servers[2].sql.deleteAll('videoShare')
await waitJobs(servers)
// Still 6 because we don't have remote shares on local videos
- const postCount = await servers[0].sql.getCount('videoShare')
+ const postCount = await servers[0].sql.getVideoShareCount()
expect(postCount).to.equal(6)
})
async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
`INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
- const res = await servers[0].sql.selectQuery(query)
+ const res = await servers[0].sql.selectQuery<{ url: string }>(query)
for (const rate of res) {
const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
`INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
- const res = await servers[0].sql.selectQuery(query)
+ const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
for (const comment of res) {
const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
signup: {
enabled: false,
limit: 5,
+ requiresApproval: false,
requiresEmailVerification: false,
minimumAge: 16
},
signup: {
enabled: true,
limit: 5,
+ requiresApproval: true,
requiresEmailVerification: true
}
}
import { MockSmtpServer } from '@server/tests/shared'
import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, ContactFormCommand, createSingleServer, killallServers, PeerTubeServer } from '@shared/server-commands'
+import {
+ cleanupTests,
+ ConfigCommand,
+ ContactFormCommand,
+ createSingleServer,
+ killallServers,
+ PeerTubeServer
+} from '@shared/server-commands'
describe('Test contact form API validators', function () {
let server: PeerTubeServer
await killallServers([ server ])
// Contact form is disabled
- await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort }, contact_form: { enabled: false } })
+ await server.run({ ...ConfigCommand.getEmailOverrideConfig(emailPort), contact_form: { enabled: false } })
await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 })
})
await killallServers([ server ])
// Email & contact form enabled
- await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort } })
+ await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
import './my-user'
import './plugins'
import './redundancy'
+import './registrations'
import './search'
import './services'
import './transcoding'
import './user-notifications'
import './user-subscriptions'
import './users-admin'
-import './users'
+import './users-emails'
import './video-blacklist'
import './video-captions'
import './video-channel-syncs'
// ---------------------------------------------------------------
before(async function () {
- this.timeout(80000)
+ this.timeout(160000)
servers = await createMultipleServers(2)
--- /dev/null
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
+import { omit } from '@shared/core-utils'
+import { HttpStatusCode, UserRole } from '@shared/models'
+import {
+ cleanupTests,
+ createSingleServer,
+ makePostBodyRequest,
+ PeerTubeServer,
+ setAccessTokensToServers,
+ setDefaultAccountAvatar,
+ setDefaultChannelAvatar
+} from '@shared/server-commands'
+
+describe('Test registrations API validators', function () {
+ let server: PeerTubeServer
+ let userToken: string
+ let moderatorToken: string
+
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(30000)
+
+ server = await createSingleServer(1)
+
+ await setAccessTokensToServers([ server ])
+ await setDefaultAccountAvatar([ server ])
+ await setDefaultChannelAvatar([ server ])
+
+ await server.config.enableSignup(false);
+
+ ({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR));
+ ({ token: userToken } = await server.users.generate('user', UserRole.USER))
+ })
+
+ describe('Register', function () {
+ const registrationPath = '/api/v1/users/register'
+ const registrationRequestPath = '/api/v1/users/registrations/request'
+
+ const baseCorrectParams = {
+ username: 'user3',
+ displayName: 'super user',
+ email: 'test3@example.com',
+ password: 'my super password',
+ registrationReason: 'my super registration reason'
+ }
+
+ describe('When registering a new user or requesting user registration', function () {
+
+ async function check (fields: any, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
+ await server.config.enableSignup(false)
+ await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus })
+
+ await server.config.enableSignup(true)
+ await makePostBodyRequest({ url: server.url, path: registrationRequestPath, fields, expectedStatus })
+ }
+
+ it('Should fail with a too small username', async function () {
+ const fields = { ...baseCorrectParams, username: '' }
+
+ await check(fields)
+ })
+
+ it('Should fail with a too long username', async function () {
+ const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
+
+ await check(fields)
+ })
+
+ it('Should fail with an incorrect username', async function () {
+ const fields = { ...baseCorrectParams, username: 'my username' }
+
+ await check(fields)
+ })
+
+ it('Should fail with a missing email', async function () {
+ const fields = omit(baseCorrectParams, [ 'email' ])
+
+ await check(fields)
+ })
+
+ it('Should fail with an invalid email', async function () {
+ const fields = { ...baseCorrectParams, email: 'test_example.com' }
+
+ await check(fields)
+ })
+
+ it('Should fail with a too small password', async function () {
+ const fields = { ...baseCorrectParams, password: 'bla' }
+
+ await check(fields)
+ })
+
+ it('Should fail with a too long password', async function () {
+ const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
+
+ await check(fields)
+ })
+
+ it('Should fail if we register a user with the same username', async function () {
+ const fields = { ...baseCorrectParams, username: 'root' }
+
+ await check(fields, HttpStatusCode.CONFLICT_409)
+ })
+
+ it('Should fail with a "peertube" username', async function () {
+ const fields = { ...baseCorrectParams, username: 'peertube' }
+
+ await check(fields, HttpStatusCode.CONFLICT_409)
+ })
+
+ it('Should fail if we register a user with the same email', async function () {
+ const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
+
+ await check(fields, HttpStatusCode.CONFLICT_409)
+ })
+
+ it('Should fail with a bad display name', async function () {
+ const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
+
+ await check(fields)
+ })
+
+ it('Should fail with a bad channel name', async function () {
+ const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
+
+ await check(fields)
+ })
+
+ it('Should fail with a bad channel display name', async function () {
+ const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
+
+ await check(fields)
+ })
+
+ it('Should fail with a channel name that is the same as username', async function () {
+ const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
+ const fields = { ...baseCorrectParams, ...source }
+
+ await check(fields)
+ })
+
+ it('Should fail with an existing channel', async function () {
+ const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
+ await server.channels.create({ attributes })
+
+ const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
+
+ await check(fields, HttpStatusCode.CONFLICT_409)
+ })
+
+ it('Should fail on a server with registration disabled', async function () {
+ this.timeout(60000)
+
+ await server.config.updateExistingSubConfig({
+ newConfig: {
+ signup: {
+ enabled: false
+ }
+ }
+ })
+
+ await server.registrations.register({ username: 'user4', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await server.registrations.requestRegistration({
+ username: 'user4',
+ registrationReason: 'reason',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should fail if the user limit is reached', async function () {
+ this.timeout(60000)
+
+ const { total } = await server.users.list()
+
+ await server.config.enableSignup(false, total)
+ await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+
+ await server.config.enableSignup(true, total)
+ await server.registrations.requestRegistration({
+ username: 'user42',
+ registrationReason: 'reason',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should succeed if the user limit is not reached', async function () {
+ this.timeout(60000)
+
+ const { total } = await server.users.list()
+
+ await server.config.enableSignup(false, total + 1)
+ await server.registrations.register({ username: 'user43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+
+ await server.config.enableSignup(true, total + 2)
+ await server.registrations.requestRegistration({
+ username: 'user44',
+ registrationReason: 'reason',
+ expectedStatus: HttpStatusCode.OK_200
+ })
+ })
+ })
+
+ describe('On direct registration', function () {
+
+ it('Should succeed with the correct params', async function () {
+ await server.config.enableSignup(false)
+
+ const fields = {
+ username: 'user_direct_1',
+ displayName: 'super user direct 1',
+ email: 'user_direct_1@example.com',
+ password: 'my super password',
+ channel: { name: 'super_user_direct_1_channel', displayName: 'super user direct 1 channel' }
+ }
+
+ await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+ })
+
+ it('Should fail if the instance requires approval', async function () {
+ this.timeout(60000)
+
+ await server.config.enableSignup(true)
+ await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ })
+ })
+
+ describe('On registration request', function () {
+
+ before(async function () {
+ this.timeout(60000)
+
+ await server.config.enableSignup(true)
+ })
+
+ it('Should fail with an invalid registration reason', async function () {
+ for (const registrationReason of [ '', 't', 't'.repeat(5000) ]) {
+ await server.registrations.requestRegistration({
+ username: 'user_request_1',
+ registrationReason,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ }
+ })
+
+ it('Should succeed with the correct params', async function () {
+ await server.registrations.requestRegistration({
+ username: 'user_request_2',
+ registrationReason: 'tt',
+ channel: {
+ displayName: 'my user request 2 channel',
+ name: 'user_request_2_channel'
+ }
+ })
+ })
+
+ it('Should fail if the user is already awaiting registration approval', async function () {
+ await server.registrations.requestRegistration({
+ username: 'user_request_2',
+ registrationReason: 'tt',
+ channel: {
+ displayName: 'my user request 42 channel',
+ name: 'user_request_42_channel'
+ },
+ expectedStatus: HttpStatusCode.CONFLICT_409
+ })
+ })
+
+ it('Should fail if the channel is already awaiting registration approval', async function () {
+ await server.registrations.requestRegistration({
+ username: 'user42',
+ registrationReason: 'tt',
+ channel: {
+ displayName: 'my user request 2 channel',
+ name: 'user_request_2_channel'
+ },
+ expectedStatus: HttpStatusCode.CONFLICT_409
+ })
+ })
+
+ it('Should fail if the instance does not require approval', async function () {
+ this.timeout(60000)
+
+ await server.config.enableSignup(false)
+
+ await server.registrations.requestRegistration({
+ username: 'user42',
+ registrationReason: 'toto',
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+ })
+ })
+
+ describe('Registrations accept/reject', function () {
+ let id1: number
+ let id2: number
+
+ before(async function () {
+ this.timeout(60000)
+
+ await server.config.enableSignup(true);
+
+ ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' }));
+ ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' }))
+ })
+
+ it('Should fail to accept/reject registration without token', async function () {
+ const options = { id: id1, moderationResponse: 'tt', token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }
+ await server.registrations.accept(options)
+ await server.registrations.reject(options)
+ })
+
+ it('Should fail to accept/reject registration with a non moderator user', async function () {
+ const options = { id: id1, moderationResponse: 'tt', token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }
+ await server.registrations.accept(options)
+ await server.registrations.reject(options)
+ })
+
+ it('Should fail to accept/reject registration with a bad registration id', async function () {
+ {
+ const options = { id: 't' as any, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+ await server.registrations.accept(options)
+ await server.registrations.reject(options)
+ }
+
+ {
+ const options = { id: 42, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+ await server.registrations.accept(options)
+ await server.registrations.reject(options)
+ }
+ })
+
+ it('Should fail to accept/reject registration with a bad moderation resposne', async function () {
+ for (const moderationResponse of [ '', 't', 't'.repeat(5000) ]) {
+ const options = { id: id1, moderationResponse, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+ await server.registrations.accept(options)
+ await server.registrations.reject(options)
+ }
+ })
+
+ it('Should succeed to accept a registration', async function () {
+ await server.registrations.accept({ id: id1, moderationResponse: 'tt', token: moderatorToken })
+ })
+
+ it('Should succeed to reject a registration', async function () {
+ await server.registrations.reject({ id: id2, moderationResponse: 'tt', token: moderatorToken })
+ })
+
+ it('Should fail to accept/reject a registration that was already accepted/rejected', async function () {
+ for (const id of [ id1, id2 ]) {
+ const options = { id, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.CONFLICT_409 }
+ await server.registrations.accept(options)
+ await server.registrations.reject(options)
+ }
+ })
+ })
+
+ describe('Registrations deletion', function () {
+ let id1: number
+ let id2: number
+ let id3: number
+
+ before(async function () {
+ ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' }));
+ ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' }));
+ ({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' }))
+
+ await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
+ await server.registrations.reject({ id: id3, moderationResponse: 'tt' })
+ })
+
+ it('Should fail to delete registration without token', async function () {
+ await server.registrations.delete({ id: id1, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+ })
+
+ it('Should fail to delete registration with a non moderator user', async function () {
+ await server.registrations.delete({ id: id1, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ })
+
+ it('Should fail to delete registration with a bad registration id', async function () {
+ await server.registrations.delete({ id: 't' as any, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ await server.registrations.delete({ id: 42, token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+ })
+
+ it('Should succeed with the correct params', async function () {
+ await server.registrations.delete({ id: id1, token: moderatorToken })
+ await server.registrations.delete({ id: id2, token: moderatorToken })
+ await server.registrations.delete({ id: id3, token: moderatorToken })
+ })
+ })
+
+ describe('Listing registrations', function () {
+ const path = '/api/v1/users/registrations'
+
+ it('Should fail with a bad start pagination', async function () {
+ await checkBadStartPagination(server.url, path, server.accessToken)
+ })
+
+ it('Should fail with a bad count pagination', async function () {
+ await checkBadCountPagination(server.url, path, server.accessToken)
+ })
+
+ it('Should fail with an incorrect sort', async function () {
+ await checkBadSortPagination(server.url, path, server.accessToken)
+ })
+
+ it('Should fail with a non authenticated user', async function () {
+ await server.registrations.list({
+ token: null,
+ expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+ })
+ })
+
+ it('Should fail with a non admin user', async function () {
+ await server.registrations.list({
+ token: userToken,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should succeed with the correct params', async function () {
+ await server.registrations.list({
+ token: moderatorToken,
+ search: 'toto'
+ })
+ })
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
this.timeout(30000)
const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
- await server.users.register(user)
+ await server.registrations.register(user)
const userToken = await server.login.getAccessToken(user)
const attributes = { fixture: 'video_short2.webm' }
this.timeout(30000)
const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
- await server.users.register(user)
+ await server.registrations.register(user)
const userToken = await server.login.getAccessToken(user)
const attributes = { fixture: 'video_short2.webm' }
import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
import {
cleanupTests,
+ ConfigCommand,
createSingleServer,
killallServers,
makeGetRequest,
await killallServers([ server ])
- const config = {
- smtp: {
- hostname: '127.0.0.1',
- port: emailPort
- }
- }
- await server.run(config)
+ await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
const fields = {
...baseCorrectParams,
--- /dev/null
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+import { MockSmtpServer } from '@server/tests/shared'
+import { HttpStatusCode, UserRole } from '@shared/models'
+import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test users API validators', function () {
+ let server: PeerTubeServer
+
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(30000)
+
+ server = await createSingleServer(1, {
+ rates_limit: {
+ ask_send_email: {
+ max: 10
+ }
+ }
+ })
+
+ await setAccessTokensToServers([ server ])
+ await server.config.enableSignup(true)
+
+ await server.users.generate('moderator2', UserRole.MODERATOR)
+
+ await server.registrations.requestRegistration({
+ username: 'request1',
+ registrationReason: 'tt'
+ })
+ })
+
+ describe('When asking a password reset', function () {
+ const path = '/api/v1/users/ask-reset-password'
+
+ it('Should fail with a missing email', async function () {
+ const fields = {}
+
+ await makePostBodyRequest({ url: server.url, path, fields })
+ })
+
+ it('Should fail with an invalid email', async function () {
+ const fields = { email: 'hello' }
+
+ await makePostBodyRequest({ url: server.url, path, fields })
+ })
+
+ it('Should success with the correct params', async function () {
+ const fields = { email: 'admin@example.com' }
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields,
+ expectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ })
+ })
+
+ describe('When asking for an account verification email', function () {
+ const path = '/api/v1/users/ask-send-verify-email'
+
+ it('Should fail with a missing email', async function () {
+ const fields = {}
+
+ await makePostBodyRequest({ url: server.url, path, fields })
+ })
+
+ it('Should fail with an invalid email', async function () {
+ const fields = { email: 'hello' }
+
+ await makePostBodyRequest({ url: server.url, path, fields })
+ })
+
+ it('Should succeed with the correct params', async function () {
+ const fields = { email: 'admin@example.com' }
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields,
+ expectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ })
+ })
+
+ describe('When asking for a registration verification email', function () {
+ const path = '/api/v1/users/registrations/ask-send-verify-email'
+
+ it('Should fail with a missing email', async function () {
+ const fields = {}
+
+ await makePostBodyRequest({ url: server.url, path, fields })
+ })
+
+ it('Should fail with an invalid email', async function () {
+ const fields = { email: 'hello' }
+
+ await makePostBodyRequest({ url: server.url, path, fields })
+ })
+
+ it('Should succeed with the correct params', async function () {
+ const fields = { email: 'request1@example.com' }
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields,
+ expectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ })
+ })
+
+ after(async function () {
+ MockSmtpServer.Instance.kill()
+
+ await cleanupTests([ server ])
+ })
+})
+++ /dev/null
-/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
-import { MockSmtpServer } from '@server/tests/shared'
-import { omit } from '@shared/core-utils'
-import { HttpStatusCode, UserRole } from '@shared/models'
-import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
-
-describe('Test users API validators', function () {
- const path = '/api/v1/users/'
- let server: PeerTubeServer
- let serverWithRegistrationDisabled: PeerTubeServer
-
- // ---------------------------------------------------------------
-
- before(async function () {
- this.timeout(30000)
-
- const res = await Promise.all([
- createSingleServer(1, { signup: { limit: 3 } }),
- createSingleServer(2)
- ])
-
- server = res[0]
- serverWithRegistrationDisabled = res[1]
-
- await setAccessTokensToServers([ server ])
-
- await server.users.generate('moderator2', UserRole.MODERATOR)
- })
-
- describe('When registering a new user', function () {
- const registrationPath = path + '/register'
- const baseCorrectParams = {
- username: 'user3',
- displayName: 'super user',
- email: 'test3@example.com',
- password: 'my super password'
- }
-
- it('Should fail with a too small username', async function () {
- const fields = { ...baseCorrectParams, username: '' }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a too long username', async function () {
- const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with an incorrect username', async function () {
- const fields = { ...baseCorrectParams, username: 'my username' }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a missing email', async function () {
- const fields = omit(baseCorrectParams, [ 'email' ])
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with an invalid email', async function () {
- const fields = { ...baseCorrectParams, email: 'test_example.com' }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a too small password', async function () {
- const fields = { ...baseCorrectParams, password: 'bla' }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a too long password', async function () {
- const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail if we register a user with the same username', async function () {
- const fields = { ...baseCorrectParams, username: 'root' }
-
- await makePostBodyRequest({
- url: server.url,
- path: registrationPath,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.CONFLICT_409
- })
- })
-
- it('Should fail with a "peertube" username', async function () {
- const fields = { ...baseCorrectParams, username: 'peertube' }
-
- await makePostBodyRequest({
- url: server.url,
- path: registrationPath,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.CONFLICT_409
- })
- })
-
- it('Should fail if we register a user with the same email', async function () {
- const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
-
- await makePostBodyRequest({
- url: server.url,
- path: registrationPath,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.CONFLICT_409
- })
- })
-
- it('Should fail with a bad display name', async function () {
- const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a bad channel name', async function () {
- const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a bad channel display name', async function () {
- const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with a channel name that is the same as username', async function () {
- const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
- const fields = { ...baseCorrectParams, ...source }
-
- await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
- })
-
- it('Should fail with an existing channel', async function () {
- const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
- await server.channels.create({ attributes })
-
- const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
-
- await makePostBodyRequest({
- url: server.url,
- path: registrationPath,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.CONFLICT_409
- })
- })
-
- it('Should succeed with the correct params', async function () {
- const fields = { ...baseCorrectParams, channel: { name: 'super_channel', displayName: 'toto' } }
-
- await makePostBodyRequest({
- url: server.url,
- path: registrationPath,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.NO_CONTENT_204
- })
- })
-
- it('Should fail on a server with registration disabled', async function () {
- const fields = {
- username: 'user4',
- email: 'test4@example.com',
- password: 'my super password 4'
- }
-
- await makePostBodyRequest({
- url: serverWithRegistrationDisabled.url,
- path: registrationPath,
- token: serverWithRegistrationDisabled.accessToken,
- fields,
- expectedStatus: HttpStatusCode.FORBIDDEN_403
- })
- })
- })
-
- describe('When registering multiple users on a server with users limit', function () {
-
- it('Should fail when after 3 registrations', async function () {
- await server.users.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
- })
-
- })
-
- describe('When asking a password reset', function () {
- const path = '/api/v1/users/ask-reset-password'
-
- it('Should fail with a missing email', async function () {
- const fields = {}
-
- await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
- })
-
- it('Should fail with an invalid email', async function () {
- const fields = { email: 'hello' }
-
- await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
- })
-
- it('Should success with the correct params', async function () {
- const fields = { email: 'admin@example.com' }
-
- await makePostBodyRequest({
- url: server.url,
- path,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.NO_CONTENT_204
- })
- })
- })
-
- describe('When asking for an account verification email', function () {
- const path = '/api/v1/users/ask-send-verify-email'
-
- it('Should fail with a missing email', async function () {
- const fields = {}
-
- await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
- })
-
- it('Should fail with an invalid email', async function () {
- const fields = { email: 'hello' }
-
- await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
- })
-
- it('Should succeed with the correct params', async function () {
- const fields = { email: 'admin@example.com' }
-
- await makePostBodyRequest({
- url: server.url,
- path,
- token: server.accessToken,
- fields,
- expectedStatus: HttpStatusCode.NO_CONTENT_204
- })
- })
- })
-
- after(async function () {
- MockSmtpServer.Instance.kill()
-
- await cleanupTests([ server, serverWithRegistrationDisabled ])
- })
-})
const video = await server.videos.get({ id: liveId })
expect(video.streamingPlaylists).to.have.lengthOf(1)
- await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
- await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
- await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
+ try {
+ await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
+ await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
+ await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
+ } catch (err) {
+ // FIXME: try to debug error in CI "Unexpected end of JSON input"
+ console.error(err)
+ throw err
+ }
await wait(100)
}
await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
})
- it('Should correctly fast reastream in a permanent live with and without save replay', async function () {
+ it('Should correctly fast restream in a permanent live with and without save replay', async function () {
this.timeout(480000)
// A test can take a long time, so prefer to run them in parallel
import './comments-notifications'
import './moderation-notifications'
import './notifications-api'
+import './registrations-notifications'
import './user-notifications'
checkNewInstanceFollower,
checkNewVideoAbuseForModerators,
checkNewVideoFromSubscription,
- checkUserRegistered,
checkVideoAutoBlacklistForModerators,
checkVideoIsPublished,
MockInstancesIndex,
let emails: object[] = []
before(async function () {
- this.timeout(120000)
+ this.timeout(50000)
const res = await prepareNotificationsTest(3)
emails = res.emails
})
it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const name = 'video for abuse ' + buildUUID()
const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
})
it('Should send a notification to moderators on local video abuse', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const name = 'video for abuse ' + buildUUID()
const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
})
it('Should send a notification to moderators on remote video abuse', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const name = 'video for abuse ' + buildUUID()
const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
})
it('Should send a notification to moderators on local comment abuse', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const name = 'video for abuse ' + buildUUID()
const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
})
it('Should send a notification to moderators on remote comment abuse', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const name = 'video for abuse ' + buildUUID()
const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
})
it('Should send a notification to moderators on local account abuse', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const username = 'user' + new Date().getTime()
const { account } = await servers[0].users.create({ username, password: 'donald' })
})
it('Should send a notification to moderators on remote account abuse', async function () {
- this.timeout(20000)
+ this.timeout(50000)
const username = 'user' + new Date().getTime()
const tmpToken = await servers[0].users.generateUserAndToken(username)
})
})
- describe('New registration', function () {
- let baseParams: CheckerBaseParams
-
- before(() => {
- baseParams = {
- server: servers[0],
- emails,
- socketNotifications: adminNotifications,
- token: servers[0].accessToken
- }
- })
-
- it('Should send a notification only to moderators when a user registers on the instance', async function () {
- this.timeout(10000)
-
- await servers[0].users.register({ username: 'user_45' })
-
- await waitJobs(servers)
-
- await checkUserRegistered({ ...baseParams, username: 'user_45', checkType: 'presence' })
-
- const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
- await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_45', checkType: 'absence' })
- })
- })
-
describe('New instance follows', function () {
const instanceIndexServer = new MockInstancesIndex()
let config: any
})
it('Should not send video publish notification if auto-blacklisted', async function () {
+ this.timeout(120000)
+
await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
})
it('Should not send a local user subscription notification if auto-blacklisted', async function () {
+ this.timeout(120000)
+
await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
})
})
it('Should send video published and unblacklist after video unblacklisted', async function () {
- this.timeout(40000)
+ this.timeout(120000)
await servers[0].blacklist.remove({ videoId: uuid })
})
it('Should send a local user subscription notification after removed from blacklist', async function () {
+ this.timeout(120000)
+
await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
})
it('Should send a remote user subscription notification after removed from blacklist', async function () {
+ this.timeout(120000)
+
await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
})
})
it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
- this.timeout(40000)
+ this.timeout(120000)
// In 2 seconds
const updateAt = new Date(new Date().getTime() + 2000)
--- /dev/null
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import {
+ CheckerBaseParams,
+ checkRegistrationRequest,
+ checkUserRegistered,
+ MockSmtpServer,
+ prepareNotificationsTest
+} from '@server/tests/shared'
+import { UserNotification } from '@shared/models'
+import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands'
+
+describe('Test registrations notifications', function () {
+ let server: PeerTubeServer
+ let userToken1: string
+
+ let userNotifications: UserNotification[] = []
+ let adminNotifications: UserNotification[] = []
+ let emails: object[] = []
+
+ let baseParams: CheckerBaseParams
+
+ before(async function () {
+ this.timeout(50000)
+
+ const res = await prepareNotificationsTest(1)
+
+ server = res.servers[0]
+ emails = res.emails
+ userToken1 = res.userAccessToken
+ adminNotifications = res.adminNotifications
+ userNotifications = res.userNotifications
+
+ baseParams = {
+ server,
+ emails,
+ socketNotifications: adminNotifications,
+ token: server.accessToken
+ }
+ })
+
+ describe('New direct registration for moderators', function () {
+
+ before(async function () {
+ await server.config.enableSignup(false)
+ })
+
+ it('Should send a notification only to moderators when a user registers on the instance', async function () {
+ this.timeout(50000)
+
+ await server.registrations.register({ username: 'user_10' })
+
+ await waitJobs([ server ])
+
+ await checkUserRegistered({ ...baseParams, username: 'user_10', checkType: 'presence' })
+
+ const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
+ await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_10', checkType: 'absence' })
+ })
+ })
+
+ describe('New registration request for moderators', function () {
+
+ before(async function () {
+ await server.config.enableSignup(true)
+ })
+
+ it('Should send a notification on new registration request', async function () {
+ this.timeout(50000)
+
+ const registrationReason = 'my reason'
+ await server.registrations.requestRegistration({ username: 'user_11', registrationReason })
+
+ await waitJobs([ server ])
+
+ await checkRegistrationRequest({ ...baseParams, username: 'user_11', registrationReason, checkType: 'presence' })
+
+ const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
+ await checkRegistrationRequest({ ...baseParams, ...userOverride, username: 'user_11', registrationReason, checkType: 'absence' })
+ })
+ })
+
+ after(async function () {
+ MockSmtpServer.Instance.kill()
+
+ await cleanupTests([ server ])
+ })
+})
// ---------------------------------------------------------------------------
it('Should upload a private video and have appropriate object storage ACL', async function () {
- this.timeout(60000)
+ this.timeout(120000)
{
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
})
it('Should upload a public video and have appropriate object storage ACL', async function () {
- this.timeout(60000)
+ this.timeout(120000)
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
await waitJobs([ server ])
})
it('Should register a user with this default setting', async function () {
- await server.users.register({ username: 'user_p2p_2' })
+ await server.registrations.register({ username: 'user_p2p_2' })
const userToken = await server.login.getAccessToken('user_p2p_2')
})
it('Should register a user with this default setting', async function () {
- await server.users.register({ username: 'user_p2p_4' })
+ await server.registrations.register({ username: 'user_p2p_4' })
const userToken = await server.login.getAccessToken('user_p2p_4')
expect(data.signup.enabled).to.be.true
expect(data.signup.limit).to.equal(4)
expect(data.signup.minimumAge).to.equal(16)
+ expect(data.signup.requiresApproval).to.be.false
expect(data.signup.requiresEmailVerification).to.be.false
expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com')
expect(data.signup.enabled).to.be.false
expect(data.signup.limit).to.equal(5)
+ expect(data.signup.requiresApproval).to.be.false
expect(data.signup.requiresEmailVerification).to.be.false
expect(data.signup.minimumAge).to.equal(10)
signup: {
enabled: false,
limit: 5,
+ requiresApproval: false,
requiresEmailVerification: false,
minimumAge: 10
},
this.timeout(5000)
await Promise.all([
- server.users.register({ username: 'user1' }),
- server.users.register({ username: 'user2' }),
- server.users.register({ username: 'user3' })
+ server.registrations.register({ username: 'user1' }),
+ server.registrations.register({ username: 'user2' }),
+ server.registrations.register({ username: 'user3' })
])
const data = await server.config.getConfig()
import { HttpStatusCode } from '@shared/models'
import {
cleanupTests,
+ ConfigCommand,
ContactFormCommand,
createSingleServer,
PeerTubeServer,
const port = await MockSmtpServer.Instance.collectEmails(emails)
- const overrideConfig = {
- smtp: {
- hostname: '127.0.0.1',
- port
- }
- }
- server = await createSingleServer(1, overrideConfig)
+ server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
await setAccessTokensToServers([ server ])
command = server.contactForm
import { expect } from 'chai'
import { MockSmtpServer } from '@server/tests/shared'
import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands'
+import {
+ cleanupTests,
+ ConfigCommand,
+ createSingleServer,
+ PeerTubeServer,
+ setAccessTokensToServers,
+ waitJobs
+} from '@shared/server-commands'
describe('Test emails', function () {
let server: PeerTubeServer
username: 'user_1',
password: 'super_password'
}
- let emailPort: number
before(async function () {
this.timeout(50000)
- emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+ const emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+ server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
- const overrideConfig = {
- smtp: {
- hostname: '127.0.0.1',
- port: emailPort
- }
- }
- server = await createSingleServer(1, overrideConfig)
await setAccessTokensToServers([ server ])
+ await server.config.enableSignup(true)
{
const created = await server.users.create({ username: user.username, password: user.password })
})
})
+ describe('When verifying a registration email', function () {
+ let registrationId: number
+ let registrationIdEmail: number
+
+ before(async function () {
+ const { id } = await server.registrations.requestRegistration({
+ username: 'request_1',
+ email: 'request_1@example.com',
+ registrationReason: 'tt'
+ })
+ registrationId = id
+ })
+
+ it('Should ask to send the verification email', async function () {
+ this.timeout(10000)
+
+ await server.registrations.askSendVerifyEmail({ email: 'request_1@example.com' })
+
+ await waitJobs(server)
+ expect(emails).to.have.lengthOf(9)
+
+ const email = emails[8]
+
+ expect(email['from'][0]['name']).equal('PeerTube')
+ expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
+ expect(email['to'][0]['address']).equal('request_1@example.com')
+ expect(email['subject']).contains('Verify')
+
+ const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
+ expect(verificationStringMatches).not.to.be.null
+
+ verificationString = verificationStringMatches[1]
+ expect(verificationString).to.not.be.undefined
+ expect(verificationString).to.have.length.above(2)
+
+ const registrationIdMatches = /registrationId=([0-9]+)/.exec(email['text'])
+ expect(registrationIdMatches).not.to.be.null
+
+ registrationIdEmail = parseInt(registrationIdMatches[1], 10)
+
+ expect(registrationId).to.equal(registrationIdEmail)
+ })
+
+ it('Should not verify the email with an invalid verification string', async function () {
+ await server.registrations.verifyEmail({
+ registrationId: registrationIdEmail,
+ verificationString: verificationString + 'b',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should verify the email', async function () {
+ await server.registrations.verifyEmail({ registrationId: registrationIdEmail, verificationString })
+ })
+ })
+
after(async function () {
MockSmtpServer.Instance.kill()
it('Should rate limit signup', async function () {
for (let i = 0; i < 10; i++) {
try {
- await server.users.register({ username: 'test' + i })
+ await server.registrations.register({ username: 'test' + i })
} catch {
// empty
}
}
- await server.users.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
+ await server.registrations.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
})
it('Should not rate limit failed signup', async function () {
await wait(7000)
for (let i = 0; i < 3; i++) {
- await server.users.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 })
+ await server.registrations.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 })
}
- await server.users.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+ await server.registrations.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
})
+import './oauth'
+import './registrations`'
import './two-factor'
import './user-subscriptions'
import './user-videos'
import './users'
import './users-multiple-servers'
-import './users-verification'
+import './users-email-verification'
--- /dev/null
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { wait } from '@shared/core-utils'
+import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
+import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+
+describe('Test oauth', function () {
+ let server: PeerTubeServer
+
+ before(async function () {
+ this.timeout(30000)
+
+ server = await createSingleServer(1, {
+ rates_limit: {
+ login: {
+ max: 30
+ }
+ }
+ })
+
+ await setAccessTokensToServers([ server ])
+ })
+
+ describe('OAuth client', function () {
+
+ function expectInvalidClient (body: PeerTubeProblemDocument) {
+ expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
+ expect(body.error).to.contain('client is invalid')
+ expect(body.type.startsWith('https://')).to.be.true
+ expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
+ }
+
+ it('Should create a new client')
+
+ it('Should return the first client')
+
+ it('Should remove the last client')
+
+ it('Should not login with an invalid client id', async function () {
+ const client = { id: 'client', secret: server.store.client.secret }
+ const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+ expectInvalidClient(body)
+ })
+
+ it('Should not login with an invalid client secret', async function () {
+ const client = { id: server.store.client.id, secret: 'coucou' }
+ const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+ expectInvalidClient(body)
+ })
+ })
+
+ describe('Login', function () {
+
+ function expectInvalidCredentials (body: PeerTubeProblemDocument) {
+ expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
+ expect(body.error).to.contain('credentials are invalid')
+ expect(body.type.startsWith('https://')).to.be.true
+ expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
+ }
+
+ it('Should not login with an invalid username', async function () {
+ const user = { username: 'captain crochet', password: server.store.user.password }
+ const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+ expectInvalidCredentials(body)
+ })
+
+ it('Should not login with an invalid password', async function () {
+ const user = { username: server.store.user.username, password: 'mew_three' }
+ const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+ expectInvalidCredentials(body)
+ })
+
+ it('Should be able to login', async function () {
+ await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
+ })
+
+ it('Should be able to login with an insensitive username', async function () {
+ const user = { username: 'RoOt', password: server.store.user.password }
+ await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
+
+ const user2 = { username: 'rOoT', password: server.store.user.password }
+ await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
+
+ const user3 = { username: 'ROOt', password: server.store.user.password }
+ await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
+ })
+ })
+
+ describe('Logout', function () {
+
+ it('Should logout (revoke token)', async function () {
+ await server.login.logout({ token: server.accessToken })
+ })
+
+ it('Should not be able to get the user information', async function () {
+ await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+ })
+
+ it('Should not be able to upload a video', async function () {
+ await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+ })
+
+ it('Should be able to login again', async function () {
+ const body = await server.login.login()
+ server.accessToken = body.access_token
+ server.refreshToken = body.refresh_token
+ })
+
+ it('Should be able to get my user information again', async function () {
+ await server.users.getMyInfo()
+ })
+
+ it('Should have an expired access token', async function () {
+ this.timeout(60000)
+
+ await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
+ await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
+
+ await killallServers([ server ])
+ await server.run()
+
+ await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+ })
+
+ it('Should not be able to refresh an access token with an expired refresh token', async function () {
+ await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ })
+
+ it('Should refresh the token', async function () {
+ this.timeout(50000)
+
+ const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
+ await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
+
+ await killallServers([ server ])
+ await server.run()
+
+ const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
+ server.accessToken = res.body.access_token
+ server.refreshToken = res.body.refresh_token
+ })
+
+ it('Should be able to get my user information again', async function () {
+ await server.users.getMyInfo()
+ })
+ })
+
+ describe('Custom token lifetime', function () {
+ before(async function () {
+ this.timeout(120_000)
+
+ await server.kill()
+ await server.run({
+ oauth2: {
+ token_lifetime: {
+ access_token: '2 seconds',
+ refresh_token: '2 seconds'
+ }
+ }
+ })
+ })
+
+ it('Should have a very short access token lifetime', async function () {
+ this.timeout(50000)
+
+ const { access_token: accessToken } = await server.login.login()
+ await server.users.getMyInfo({ token: accessToken })
+
+ await wait(3000)
+ await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+ })
+
+ it('Should have a very short refresh token lifetime', async function () {
+ this.timeout(50000)
+
+ const { refresh_token: refreshToken } = await server.login.login()
+ await server.login.refreshToken({ refreshToken })
+
+ await wait(3000)
+ await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ })
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
--- /dev/null
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { MockSmtpServer } from '@server/tests/shared'
+import { UserRegistrationState, UserRole } from '@shared/models'
+import {
+ cleanupTests,
+ ConfigCommand,
+ createSingleServer,
+ PeerTubeServer,
+ setAccessTokensToServers,
+ waitJobs
+} from '@shared/server-commands'
+
+describe('Test registrations', function () {
+ let server: PeerTubeServer
+
+ const emails: object[] = []
+ let emailPort: number
+
+ before(async function () {
+ this.timeout(30000)
+
+ emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+
+ server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
+
+ await setAccessTokensToServers([ server ])
+ await server.config.enableSignup(false)
+ })
+
+ describe('Direct registrations of a new user', function () {
+ let user1Token: string
+
+ it('Should register a new user', async function () {
+ const user = { displayName: 'super user 1', username: 'user_1', password: 'my super password' }
+ const channel = { name: 'my_user_1_channel', displayName: 'my channel rocks' }
+
+ await server.registrations.register({ ...user, channel })
+ })
+
+ it('Should be able to login with this registered user', async function () {
+ const user1 = { username: 'user_1', password: 'my super password' }
+
+ user1Token = await server.login.getAccessToken(user1)
+ })
+
+ it('Should have the correct display name', async function () {
+ const user = await server.users.getMyInfo({ token: user1Token })
+ expect(user.account.displayName).to.equal('super user 1')
+ })
+
+ it('Should have the correct video quota', async function () {
+ const user = await server.users.getMyInfo({ token: user1Token })
+ expect(user.videoQuota).to.equal(5 * 1024 * 1024)
+ })
+
+ it('Should have created the channel', async function () {
+ const { displayName } = await server.channels.get({ channelName: 'my_user_1_channel' })
+
+ expect(displayName).to.equal('my channel rocks')
+ })
+
+ it('Should remove me', async function () {
+ {
+ const { data } = await server.users.list()
+ expect(data.find(u => u.username === 'user_1')).to.not.be.undefined
+ }
+
+ await server.users.deleteMe({ token: user1Token })
+
+ {
+ const { data } = await server.users.list()
+ expect(data.find(u => u.username === 'user_1')).to.be.undefined
+ }
+ })
+ })
+
+ describe('Registration requests', function () {
+ let id2: number
+ let id3: number
+ let id4: number
+
+ let user2Token: string
+ let user3Token: string
+
+ before(async function () {
+ this.timeout(60000)
+
+ await server.config.enableSignup(true)
+
+ {
+ const { id } = await server.registrations.requestRegistration({
+ username: 'user4',
+ registrationReason: 'registration reason 4'
+ })
+
+ id4 = id
+ }
+ })
+
+ it('Should request a registration without a channel', async function () {
+ {
+ const { id } = await server.registrations.requestRegistration({
+ username: 'user2',
+ displayName: 'my super user 2',
+ email: 'user2@example.com',
+ password: 'user2password',
+ registrationReason: 'registration reason 2'
+ })
+
+ id2 = id
+ }
+ })
+
+ it('Should request a registration with a channel', async function () {
+ const { id } = await server.registrations.requestRegistration({
+ username: 'user3',
+ displayName: 'my super user 3',
+ channel: {
+ displayName: 'my user 3 channel',
+ name: 'super_user3_channel'
+ },
+ email: 'user3@example.com',
+ password: 'user3password',
+ registrationReason: 'registration reason 3'
+ })
+
+ id3 = id
+ })
+
+ it('Should list these registration requests', async function () {
+ {
+ const { total, data } = await server.registrations.list({ sort: '-createdAt' })
+ expect(total).to.equal(3)
+ expect(data).to.have.lengthOf(3)
+
+ {
+ expect(data[0].id).to.equal(id3)
+ expect(data[0].username).to.equal('user3')
+ expect(data[0].accountDisplayName).to.equal('my super user 3')
+
+ expect(data[0].channelDisplayName).to.equal('my user 3 channel')
+ expect(data[0].channelHandle).to.equal('super_user3_channel')
+
+ expect(data[0].createdAt).to.exist
+ expect(data[0].updatedAt).to.exist
+
+ expect(data[0].email).to.equal('user3@example.com')
+ expect(data[0].emailVerified).to.be.null
+
+ expect(data[0].moderationResponse).to.be.null
+ expect(data[0].registrationReason).to.equal('registration reason 3')
+ expect(data[0].state.id).to.equal(UserRegistrationState.PENDING)
+ expect(data[0].state.label).to.equal('Pending')
+ expect(data[0].user).to.be.null
+ }
+
+ {
+ expect(data[1].id).to.equal(id2)
+ expect(data[1].username).to.equal('user2')
+ expect(data[1].accountDisplayName).to.equal('my super user 2')
+
+ expect(data[1].channelDisplayName).to.be.null
+ expect(data[1].channelHandle).to.be.null
+
+ expect(data[1].createdAt).to.exist
+ expect(data[1].updatedAt).to.exist
+
+ expect(data[1].email).to.equal('user2@example.com')
+ expect(data[1].emailVerified).to.be.null
+
+ expect(data[1].moderationResponse).to.be.null
+ expect(data[1].registrationReason).to.equal('registration reason 2')
+ expect(data[1].state.id).to.equal(UserRegistrationState.PENDING)
+ expect(data[1].state.label).to.equal('Pending')
+ expect(data[1].user).to.be.null
+ }
+
+ {
+ expect(data[2].username).to.equal('user4')
+ }
+ }
+
+ {
+ const { total, data } = await server.registrations.list({ count: 1, start: 1, sort: 'createdAt' })
+
+ expect(total).to.equal(3)
+ expect(data).to.have.lengthOf(1)
+ expect(data[0].id).to.equal(id2)
+ }
+
+ {
+ const { total, data } = await server.registrations.list({ search: 'user3' })
+ expect(total).to.equal(1)
+ expect(data).to.have.lengthOf(1)
+ expect(data[0].id).to.equal(id3)
+ }
+ })
+
+ it('Should reject a registration request', async function () {
+ await server.registrations.reject({ id: id4, moderationResponse: 'I do not want id 4 on this instance' })
+ })
+
+ it('Should have sent an email to the user explanining the registration has been rejected', async function () {
+ this.timeout(50000)
+
+ await waitJobs([ server ])
+
+ const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com')
+ expect(email).to.exist
+
+ expect(email['subject']).to.contain('been rejected')
+ expect(email['text']).to.contain('been rejected')
+ expect(email['text']).to.contain('I do not want id 4 on this instance')
+ })
+
+ it('Should accept registration requests', async function () {
+ await server.registrations.accept({ id: id2, moderationResponse: 'Welcome id 2' })
+ await server.registrations.accept({ id: id3, moderationResponse: 'Welcome id 3' })
+ })
+
+ it('Should have sent an email to the user explanining the registration has been accepted', async function () {
+ this.timeout(50000)
+
+ await waitJobs([ server ])
+
+ {
+ const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com')
+ expect(email).to.exist
+
+ expect(email['subject']).to.contain('been accepted')
+ expect(email['text']).to.contain('been accepted')
+ expect(email['text']).to.contain('Welcome id 2')
+ }
+
+ {
+ const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com')
+ expect(email).to.exist
+
+ expect(email['subject']).to.contain('been accepted')
+ expect(email['text']).to.contain('been accepted')
+ expect(email['text']).to.contain('Welcome id 3')
+ }
+ })
+
+ it('Should login with these users', async function () {
+ user2Token = await server.login.getAccessToken({ username: 'user2', password: 'user2password' })
+ user3Token = await server.login.getAccessToken({ username: 'user3', password: 'user3password' })
+ })
+
+ it('Should have created the appropriate attributes for user 2', async function () {
+ const me = await server.users.getMyInfo({ token: user2Token })
+
+ expect(me.username).to.equal('user2')
+ expect(me.account.displayName).to.equal('my super user 2')
+ expect(me.videoQuota).to.equal(5 * 1024 * 1024)
+ expect(me.videoChannels[0].name).to.equal('user2_channel')
+ expect(me.videoChannels[0].displayName).to.equal('Main user2 channel')
+ expect(me.role.id).to.equal(UserRole.USER)
+ expect(me.email).to.equal('user2@example.com')
+ })
+
+ it('Should have created the appropriate attributes for user 3', async function () {
+ const me = await server.users.getMyInfo({ token: user3Token })
+
+ expect(me.username).to.equal('user3')
+ expect(me.account.displayName).to.equal('my super user 3')
+ expect(me.videoQuota).to.equal(5 * 1024 * 1024)
+ expect(me.videoChannels[0].name).to.equal('super_user3_channel')
+ expect(me.videoChannels[0].displayName).to.equal('my user 3 channel')
+ expect(me.role.id).to.equal(UserRole.USER)
+ expect(me.email).to.equal('user3@example.com')
+ })
+
+ it('Should list these accepted/rejected registration requests', async function () {
+ const { data } = await server.registrations.list({ sort: 'createdAt' })
+ const { data: users } = await server.users.list()
+
+ {
+ expect(data[0].id).to.equal(id4)
+ expect(data[0].state.id).to.equal(UserRegistrationState.REJECTED)
+ expect(data[0].state.label).to.equal('Rejected')
+
+ expect(data[0].moderationResponse).to.equal('I do not want id 4 on this instance')
+ expect(data[0].user).to.be.null
+
+ expect(users.find(u => u.username === 'user4')).to.not.exist
+ }
+
+ {
+ expect(data[1].id).to.equal(id2)
+ expect(data[1].state.id).to.equal(UserRegistrationState.ACCEPTED)
+ expect(data[1].state.label).to.equal('Accepted')
+
+ expect(data[1].moderationResponse).to.equal('Welcome id 2')
+ expect(data[1].user).to.exist
+
+ const user2 = users.find(u => u.username === 'user2')
+ expect(data[1].user.id).to.equal(user2.id)
+ }
+
+ {
+ expect(data[2].id).to.equal(id3)
+ expect(data[2].state.id).to.equal(UserRegistrationState.ACCEPTED)
+ expect(data[2].state.label).to.equal('Accepted')
+
+ expect(data[2].moderationResponse).to.equal('Welcome id 3')
+ expect(data[2].user).to.exist
+
+ const user3 = users.find(u => u.username === 'user3')
+ expect(data[2].user.id).to.equal(user3.id)
+ }
+ })
+
+ it('Shoulde delete a registration', async function () {
+ await server.registrations.delete({ id: id2 })
+ await server.registrations.delete({ id: id3 })
+
+ const { total, data } = await server.registrations.list()
+ expect(total).to.equal(1)
+ expect(data).to.have.lengthOf(1)
+ expect(data[0].id).to.equal(id4)
+
+ const { data: users } = await server.users.list()
+
+ for (const username of [ 'user2', 'user3' ]) {
+ expect(users.find(u => u.username === username)).to.exist
+ }
+ })
+
+ it('Should be able to prevent email delivery on accept/reject', async function () {
+ this.timeout(50000)
+
+ let id1: number
+ let id2: number
+
+ {
+ const { id } = await server.registrations.requestRegistration({
+ username: 'user7',
+ email: 'user7@example.com',
+ registrationReason: 'tt'
+ })
+ id1 = id
+ }
+ {
+ const { id } = await server.registrations.requestRegistration({
+ username: 'user8',
+ email: 'user8@example.com',
+ registrationReason: 'tt'
+ })
+ id2 = id
+ }
+
+ await server.registrations.accept({ id: id1, moderationResponse: 'tt', preventEmailDelivery: true })
+ await server.registrations.reject({ id: id2, moderationResponse: 'tt', preventEmailDelivery: true })
+
+ await waitJobs([ server ])
+
+ const filtered = emails.filter(e => {
+ const address = e['to'][0]['address']
+ return address === 'user7@example.com' || address === 'user8@example.com'
+ })
+
+ expect(filtered).to.have.lengthOf(0)
+ })
+
+ it('Should request a registration without a channel, that will conflict with an already existing channel', async function () {
+ let id1: number
+ let id2: number
+
+ {
+ const { id } = await server.registrations.requestRegistration({
+ registrationReason: 'tt',
+ username: 'user5',
+ password: 'user5password',
+ channel: {
+ displayName: 'channel 6',
+ name: 'user6_channel'
+ }
+ })
+
+ id1 = id
+ }
+
+ {
+ const { id } = await server.registrations.requestRegistration({
+ registrationReason: 'tt',
+ username: 'user6',
+ password: 'user6password'
+ })
+
+ id2 = id
+ }
+
+ await server.registrations.accept({ id: id1, moderationResponse: 'tt' })
+ await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
+
+ const user5Token = await server.login.getAccessToken('user5', 'user5password')
+ const user6Token = await server.login.getAccessToken('user6', 'user6password')
+
+ const user5 = await server.users.getMyInfo({ token: user5Token })
+ const user6 = await server.users.getMyInfo({ token: user6Token })
+
+ expect(user5.videoChannels[0].name).to.equal('user6_channel')
+ expect(user6.videoChannels[0].name).to.equal('user6_channel-1')
+ })
+ })
+
+ after(async function () {
+ MockSmtpServer.Instance.kill()
+
+ await cleanupTests([ server ])
+ })
+})
import { expect } from 'chai'
import { MockSmtpServer } from '@server/tests/shared'
import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands'
-
-describe('Test users account verification', function () {
+import {
+ cleanupTests,
+ ConfigCommand,
+ createSingleServer,
+ PeerTubeServer,
+ setAccessTokensToServers,
+ waitJobs
+} from '@shared/server-commands'
+
+describe('Test users email verification', function () {
let server: PeerTubeServer
let userId: number
let userAccessToken: string
this.timeout(30000)
const port = await MockSmtpServer.Instance.collectEmails(emails)
-
- const overrideConfig = {
- smtp: {
- hostname: '127.0.0.1',
- port
- }
- }
- server = await createSingleServer(1, overrideConfig)
+ server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
await setAccessTokensToServers([ server ])
})
it('Should register user and send verification email if verification required', async function () {
this.timeout(30000)
- await server.config.updateCustomSubConfig({
+ await server.config.updateExistingSubConfig({
newConfig: {
signup: {
enabled: true,
+ requiresApproval: false,
requiresEmailVerification: true,
limit: 10
}
}
})
- await server.users.register(user1)
+ await server.registrations.register(user1)
await waitJobs(server)
expectedEmailsLength++
it('Should register user not requiring email verification if setting not enabled', async function () {
this.timeout(5000)
- await server.config.updateCustomSubConfig({
+ await server.config.updateExistingSubConfig({
newConfig: {
signup: {
- enabled: true,
- requiresEmailVerification: false,
- limit: 10
+ requiresEmailVerification: false
}
}
})
- await server.users.register(user2)
+ await server.registrations.register(user2)
await waitJobs(server)
expect(emails).to.have.lengthOf(expectedEmailsLength)
await server.config.updateCustomSubConfig({
newConfig: {
signup: {
- enabled: true,
- requiresEmailVerification: true,
- limit: 10
+ requiresEmailVerification: true
}
}
})
import { expect } from 'chai'
import { testImage } from '@server/tests/shared'
-import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
-import {
- cleanupTests,
- createSingleServer,
- killallServers,
- makePutBodyRequest,
- PeerTubeServer,
- setAccessTokensToServers
-} from '@shared/server-commands'
+import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
describe('Test users', function () {
let server: PeerTubeServer
await server.plugins.install({ npmName: 'peertube-theme-background-red' })
})
- describe('OAuth client', function () {
- it('Should create a new client')
-
- it('Should return the first client')
-
- it('Should remove the last client')
-
- it('Should not login with an invalid client id', async function () {
- const client = { id: 'client', secret: server.store.client.secret }
- const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
- expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
- expect(body.error).to.contain('client is invalid')
- expect(body.type.startsWith('https://')).to.be.true
- expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
- })
-
- it('Should not login with an invalid client secret', async function () {
- const client = { id: server.store.client.id, secret: 'coucou' }
- const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
- expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
- expect(body.error).to.contain('client is invalid')
- expect(body.type.startsWith('https://')).to.be.true
- expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
- })
- })
-
- describe('Login', function () {
-
- it('Should not login with an invalid username', async function () {
- const user = { username: 'captain crochet', password: server.store.user.password }
- const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
- expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
- expect(body.error).to.contain('credentials are invalid')
- expect(body.type.startsWith('https://')).to.be.true
- expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
- })
-
- it('Should not login with an invalid password', async function () {
- const user = { username: server.store.user.username, password: 'mew_three' }
- const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
-
- expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
- expect(body.error).to.contain('credentials are invalid')
- expect(body.type.startsWith('https://')).to.be.true
- expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
- })
-
- it('Should not be able to upload a video', async function () {
- token = 'my_super_token'
-
- await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
- })
-
- it('Should not be able to follow', async function () {
- token = 'my_super_token'
-
- await server.follows.follow({
- hosts: [ 'http://example.com' ],
- token,
- expectedStatus: HttpStatusCode.UNAUTHORIZED_401
- })
- })
-
- it('Should not be able to unfollow')
-
- it('Should be able to login', async function () {
- const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
-
- token = body.access_token
- })
-
- it('Should be able to login with an insensitive username', async function () {
- const user = { username: 'RoOt', password: server.store.user.password }
- await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
-
- const user2 = { username: 'rOoT', password: server.store.user.password }
- await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
-
- const user3 = { username: 'ROOt', password: server.store.user.password }
- await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
- })
- })
-
- describe('Logout', function () {
- it('Should logout (revoke token)', async function () {
- await server.login.logout({ token: server.accessToken })
- })
-
- it('Should not be able to get the user information', async function () {
- await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
- })
-
- it('Should not be able to upload a video', async function () {
- await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
- })
-
- it('Should not be able to rate a video', async function () {
- const path = '/api/v1/videos/'
- const data = {
- rating: 'likes'
- }
-
- const options = {
- url: server.url,
- path: path + videoId,
- token: 'wrong token',
- fields: data,
- expectedStatus: HttpStatusCode.UNAUTHORIZED_401
- }
- await makePutBodyRequest(options)
- })
-
- it('Should be able to login again', async function () {
- const body = await server.login.login()
- server.accessToken = body.access_token
- server.refreshToken = body.refresh_token
- })
-
- it('Should be able to get my user information again', async function () {
- await server.users.getMyInfo()
- })
-
- it('Should have an expired access token', async function () {
- this.timeout(60000)
-
- await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
- await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
-
- await killallServers([ server ])
- await server.run()
-
- await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
- })
-
- it('Should not be able to refresh an access token with an expired refresh token', async function () {
- await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
- })
-
- it('Should refresh the token', async function () {
- this.timeout(50000)
-
- const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
- await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
-
- await killallServers([ server ])
- await server.run()
-
- const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
- server.accessToken = res.body.access_token
- server.refreshToken = res.body.refresh_token
- })
-
- it('Should be able to get my user information again', async function () {
- await server.users.getMyInfo()
- })
- })
-
describe('Creating a user', function () {
it('Should be able to create a new user', async function () {
})
describe('Updating another user', function () {
+
it('Should be able to update another user', async function () {
await server.users.update({
userId,
})
})
- describe('Video blacklists', function () {
-
- it('Should be able to list my video blacklist', async function () {
- await server.blacklist.list({ token: userToken })
- })
- })
-
describe('Remove a user', function () {
before(async function () {
})
})
- describe('Registering a new user', function () {
- let user15AccessToken: string
-
- it('Should register a new user', async function () {
- const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' }
- const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' }
-
- await server.users.register({ ...user, channel })
- })
-
- it('Should be able to login with this registered user', async function () {
- const user15 = {
- username: 'user_15',
- password: 'my super password'
- }
-
- user15AccessToken = await server.login.getAccessToken(user15)
- })
-
- it('Should have the correct display name', async function () {
- const user = await server.users.getMyInfo({ token: user15AccessToken })
- expect(user.account.displayName).to.equal('super user 15')
- })
-
- it('Should have the correct video quota', async function () {
- const user = await server.users.getMyInfo({ token: user15AccessToken })
- expect(user.videoQuota).to.equal(5 * 1024 * 1024)
- })
-
- it('Should have created the channel', async function () {
- const { displayName } = await server.channels.get({ channelName: 'my_user_15_channel' })
-
- expect(displayName).to.equal('my channel rocks')
- })
-
- it('Should remove me', async function () {
- {
- const { data } = await server.users.list()
- expect(data.find(u => u.username === 'user_15')).to.not.be.undefined
- }
-
- await server.users.deleteMe({ token: user15AccessToken })
-
- {
- const { data } = await server.users.list()
- expect(data.find(u => u.username === 'user_15')).to.be.undefined
- }
- })
- })
-
describe('User blocking', function () {
- let user16Id
- let user16AccessToken
+ let user16Id: number
+ let user16AccessToken: string
+
const user16 = {
username: 'user_16',
password: 'my super password'
})
}
- runSuite('youtube-dl')
+ // FIXME: suite is broken with youtube-dl
+ // runSuite('youtube-dl')
runSuite('yt-dlp')
})
await setDefaultAccountAvatar(server)
userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
+ await setDefaultChannelAvatar(server, 'user1_channel')
+ await setDefaultAccountAvatar(server, userAccessTokenServer1)
command = server.comments
})
expect(body.data[2].totalReplies).to.equal(0)
})
+ it('Should list the and sort them by total replies', async function () {
+ const body = await command.listThreads({ videoId: videoUUID, sort: 'totalReplies' })
+
+ expect(body.data[2].text).to.equal('my super first comment')
+ expect(body.data[2].totalReplies).to.equal(3)
+ })
+
it('Should delete a reply', async function () {
await command.delete({ videoId, commentId: replyToDeleteId })
await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
- expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1)
+ expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
+ expect(tree.comment.totalReplies).to.equal(2)
})
})
describe('All instance comments', function () {
it('Should list instance comments as admin', async function () {
- const { data } = await command.listForAdmin({ start: 0, count: 1 })
+ {
+ const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
+
+ expect(total).to.equal(7)
+ expect(data).to.have.lengthOf(1)
+ expect(data[0].text).to.equal('my second answer to thread 4')
+ expect(data[0].account.name).to.equal('root')
+ expect(data[0].account.displayName).to.equal('root')
+ expect(data[0].account.avatars).to.have.lengthOf(2)
+ }
+
+ {
+ const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
- expect(data[0].text).to.equal('my second answer to thread 4')
+ expect(total).to.equal(7)
+ expect(data).to.have.lengthOf(2)
+
+ expect(data[0].account.avatars).to.have.lengthOf(2)
+ expect(data[1].account.avatars).to.have.lengthOf(2)
+ }
})
it('Should filter instance comments by isLocal', async function () {
const videoTorrent = await server.videos.get({ id: idTorrent })
for (const video of [ videoMagnet, videoTorrent ]) {
- expect(video.category.label).to.equal('Misc')
+ expect(video.category.label).to.equal('Unknown')
expect(video.licence.label).to.equal('Unknown')
expect(video.language.label).to.equal('Unknown')
expect(video.nsfw).to.be.false
import { expect } from 'chai'
import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared'
import { wait } from '@shared/core-utils'
+import { uuidToShort } from '@shared/extra-utils'
import {
HttpStatusCode,
VideoPlaylist,
setDefaultVideoChannel,
waitJobs
} from '@shared/server-commands'
-import { uuidToShort } from '@shared/extra-utils'
async function checkPlaylistElementType (
servers: PeerTubeServer[],
await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
}
})
-
- it('Should hide the video if it is NSFW', async function () {
- const body = await commands[0].listVideos({ token: userTokenServer1, playlistId: playlistServer1UUID2, query: { nsfw: 'false' } })
- expect(body.total).to.equal(3)
-
- const elements = body.data
- const element = elements.find(e => e.position === 3)
-
- expect(element).to.exist
- expect(element.video).to.be.null
- expect(element.type).to.equal(VideoPlaylistElementType.UNAVAILABLE)
- })
-
})
describe('Managing playlist elements', function () {
})
it('Should allow signup', async function () {
- await servers[0].users.register({
+ await servers[0].registrations.register({
username: 'user1',
displayName: 'user 1'
})
})
it('Should detect a signup as SPAM', async function () {
- await servers[0].users.register({
+ await servers[0].registrations.register({
username: 'user2',
displayName: 'user 2',
email: 'akismet-guaranteed-spam@example.com',
let port: number
before(async function () {
- this.timeout(60000)
+ this.timeout(120000)
servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
let port: number
before(async function () {
- this.timeout(30000)
+ this.timeout(120000)
servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
- expect(jsonObj.items[0].author.name).to.equal('root')
+ expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
{
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
- expect(jsonObj.items[0].author.name).to.equal('john')
+ expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
for (const server of servers) {
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
- expect(jsonObj.items[0].author.name).to.equal('root')
+ expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
{
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
- expect(jsonObj.items[0].author.name).to.equal('john')
+ expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
for (const server of servers) {
username: 'kefka',
email: 'kefka@example.com',
role: 0,
- displayName: 'Kefka Palazzo'
+ displayName: 'Kefka Palazzo',
+ adminFlags: 1,
+ videoQuota: 42000,
+ videoQuotaDaily: 42100,
+
+ // Always use new value except for videoQuotaDaily field
+ userUpdater: ({ fieldName, currentValue, newValue }) => {
+ if (fieldName === 'videoQuotaDaily') return currentValue
+
+ return newValue
+ }
})
},
hookTokenValidity: (options) => {
return res.json({ serverConfig })
})
+ router.get('/server-listening-config', async (req, res) => {
+ const config = await peertubeHelpers.config.getServerListeningConfig()
+
+ return res.json({ config })
+ })
+
router.get('/static-route', async (req, res) => {
const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
if (body.id === 'laguna' && body.password === 'laguna password') {
return Promise.resolve({
username: 'laguna',
- email: 'laguna@example.com'
+ email: 'laguna@example.com',
+ displayName: 'Laguna Loire',
+ adminFlags: 1,
+ videoQuota: 42000,
+ videoQuotaDaily: 42100,
+
+ // Always use new value except for videoQuotaDaily field
+ userUpdater: ({ fieldName, currentValue, newValue }) => {
+ if (fieldName === 'videoQuotaDaily') return currentValue
+
+ return newValue
+ }
})
}
}
})
- registerHook({
- target: 'filter:api.user.signup.allowed.result',
- handler: (result, params) => {
- if (params && params.body && params.body.email && params.body.email.includes('jma')) {
- return { allowed: false, errorMessage: 'No jma' }
+ {
+ registerHook({
+ target: 'filter:api.user.signup.allowed.result',
+ handler: (result, params) => {
+ if (params && params.body && params.body.email && params.body.email.includes('jma 1')) {
+ return { allowed: false, errorMessage: 'No jma 1' }
+ }
+
+ return result
}
+ })
- return result
- }
- })
+ registerHook({
+ target: 'filter:api.user.request-signup.allowed.result',
+ handler: (result, params) => {
+ if (params && params.body && params.body.email && params.body.email.includes('jma 2')) {
+ return { allowed: false, errorMessage: 'No jma 2' }
+ }
+
+ return result
+ }
+ })
+ }
registerHook({
target: 'filter:api.download.torrent.allowed.result',
registerHook({
target: 'filter:api.download.video.allowed.result',
- handler: (result, params) => {
+ handler: async (result, params) => {
+ const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
+ if (loggedInUser) return { allowed: true }
+
if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
return { allowed: false, errorMessage: 'Cao Cao' }
}
import './markdown'
import './request'
import './validator'
+import './version'
--- /dev/null
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { compareSemVer } from '@shared/core-utils'
+
+describe('Version', function () {
+
+ it('Should correctly compare two stable versions', async function () {
+ expect(compareSemVer('3.4.0', '3.5.0')).to.be.below(0)
+ expect(compareSemVer('3.5.0', '3.4.0')).to.be.above(0)
+
+ expect(compareSemVer('3.4.0', '4.1.0')).to.be.below(0)
+ expect(compareSemVer('4.1.0', '3.4.0')).to.be.above(0)
+
+ expect(compareSemVer('3.4.0', '3.4.1')).to.be.below(0)
+ expect(compareSemVer('3.4.1', '3.4.0')).to.be.above(0)
+ })
+
+ it('Should correctly compare two unstable version', async function () {
+ expect(compareSemVer('3.4.0-alpha', '3.4.0-beta.1')).to.be.below(0)
+ expect(compareSemVer('3.4.0-alpha.1', '3.4.0-beta.1')).to.be.below(0)
+ expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
+ expect(compareSemVer('3.4.0-beta.1', '3.5.0-alpha.1')).to.be.below(0)
+
+ expect(compareSemVer('3.4.0-alpha.1', '3.4.0-nightly.4')).to.be.below(0)
+ expect(compareSemVer('3.4.0-nightly.3', '3.4.0-nightly.4')).to.be.below(0)
+ expect(compareSemVer('3.3.0-nightly.5', '3.4.0-nightly.4')).to.be.below(0)
+ })
+
+ it('Should correctly compare a stable and unstable versions', async function () {
+ expect(compareSemVer('3.4.0', '3.4.1-beta.1')).to.be.below(0)
+ expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
+ expect(compareSemVer('3.4.0-beta.1', '3.4.0')).to.be.below(0)
+ expect(compareSemVer('3.4.0-nightly.4', '3.4.0')).to.be.below(0)
+ })
+})
let userId: number
it('Should run action:api.user.registered', async function () {
- await servers[0].users.register({ username: 'registered_user' })
+ await servers[0].registrations.register({ username: 'registered_user' })
await checkHook('action:api.user.registered')
})
import { expect } from 'chai'
import { wait } from '@shared/core-utils'
-import { HttpStatusCode, UserRole } from '@shared/models'
+import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
import {
cleanupTests,
createSingleServer,
let kefkaAccessToken: string
let kefkaRefreshToken: string
+ let kefkaId: number
let externalAuthToken: string
expect(body.account.displayName).to.equal('cyan')
expect(body.email).to.equal('cyan@example.com')
expect(body.role.id).to.equal(UserRole.USER)
+ expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
+ expect(body.videoQuota).to.equal(5242880)
+ expect(body.videoQuotaDaily).to.equal(-1)
}
})
expect(body.account.displayName).to.equal('Kefka Palazzo')
expect(body.email).to.equal('kefka@example.com')
expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
+ expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
+ expect(body.videoQuota).to.equal(42000)
+ expect(body.videoQuotaDaily).to.equal(42100)
+
+ kefkaId = body.id
}
})
expect(body.role.id).to.equal(UserRole.USER)
})
+ it('Should login Kefka and update the profile', async function () {
+ {
+ await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
+ await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
+
+ const body = await server.users.getMyInfo({ token: kefkaAccessToken })
+ expect(body.username).to.equal('kefka')
+ expect(body.account.displayName).to.equal('kefka updated')
+ expect(body.videoQuota).to.equal(43000)
+ expect(body.videoQuotaDaily).to.equal(43100)
+ }
+
+ {
+ const res = await loginExternal({
+ server,
+ npmName: 'test-external-auth-one',
+ authName: 'external-auth-2',
+ username: 'kefka'
+ })
+
+ kefkaAccessToken = res.access_token
+ kefkaRefreshToken = res.refresh_token
+
+ const body = await server.users.getMyInfo({ token: kefkaAccessToken })
+ expect(body.username).to.equal('kefka')
+ expect(body.account.displayName).to.equal('Kefka Palazzo')
+ expect(body.videoQuota).to.equal(42000)
+ expect(body.videoQuotaDaily).to.equal(43100)
+ }
+ })
+
it('Should not update an external auth email', async function () {
await server.users.updateMe({
token: cyanAccessToken,
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
-import { HttpStatusCode, VideoDetails, VideoImportState, VideoPlaylist, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
+import {
+ HttpStatusCode,
+ PeerTubeProblemDocument,
+ VideoDetails,
+ VideoImportState,
+ VideoPlaylist,
+ VideoPlaylistPrivacy,
+ VideoPrivacy
+} from '@shared/models'
import {
cleanupTests,
createMultipleServers,
describe('Should run filter:api.user.signup.allowed.result', function () {
+ before(async function () {
+ await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: false } } })
+ })
+
it('Should run on config endpoint', async function () {
const body = await servers[0].config.getConfig()
expect(body.signup.allowed).to.be.true
})
it('Should allow a signup', async function () {
- await servers[0].users.register({ username: 'john', password: 'password' })
+ await servers[0].registrations.register({ username: 'john1' })
})
it('Should not allow a signup', async function () {
- const res = await servers[0].users.register({
- username: 'jma',
- password: 'password',
+ const res = await servers[0].registrations.register({
+ username: 'jma 1',
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
- expect(res.body.error).to.equal('No jma')
+ expect(res.body.error).to.equal('No jma 1')
+ })
+ })
+
+ describe('Should run filter:api.user.request-signup.allowed.result', function () {
+
+ before(async function () {
+ await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: true } } })
+ })
+
+ it('Should run on config endpoint', async function () {
+ const body = await servers[0].config.getConfig()
+ expect(body.signup.allowed).to.be.true
+ })
+
+ it('Should allow a signup request', async function () {
+ await servers[0].registrations.requestRegistration({ username: 'john2', registrationReason: 'tt' })
+ })
+
+ it('Should not allow a signup request', async function () {
+ const body = await servers[0].registrations.requestRegistration({
+ username: 'jma 2',
+ registrationReason: 'tt',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+
+ expect((body as unknown as PeerTubeProblemDocument).error).to.equal('No jma 2')
})
})
describe('Download hooks', function () {
const downloadVideos: VideoDetails[] = []
+ let downloadVideo2Token: string
before(async function () {
this.timeout(120000)
for (const uuid of uuids) {
downloadVideos.push(await servers[0].videos.get({ id: uuid }))
}
+
+ downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
})
it('Should run filter:api.download.torrent.allowed.result', async function () {
it('Should run filter:api.download.video.allowed.result', async function () {
{
- const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ const refused = downloadVideos[1].files[0].fileDownloadUrl
+ const allowed = [
+ downloadVideos[0].files[0].fileDownloadUrl,
+ downloadVideos[2].files[0].fileDownloadUrl
+ ]
+
+ const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
expect(res.body.error).to.equal('Cao Cao')
- await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
- await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
+ for (const url of allowed) {
+ await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+ await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+ }
}
{
- const res = await makeRawRequest({
- url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
- expectedStatus: HttpStatusCode.FORBIDDEN_403
- })
+ const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
- expect(res.body.error).to.equal('Sun Jian')
+ const allowed = [
+ downloadVideos[2].files[0].fileDownloadUrl,
+ downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
+ downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
+ ]
- await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 })
+ // Only streaming playlist is refuse
+ const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ expect(res.body.error).to.equal('Sun Jian')
- await makeRawRequest({
- url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
- expectedStatus: HttpStatusCode.OK_200
- })
+ // But not we there is a user in res
+ await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
+ await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
- await makeRawRequest({
- url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl,
- expectedStatus: HttpStatusCode.OK_200
- })
+ // Other files work
+ for (const url of allowed) {
+ await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
+ }
}
})
})
let lagunaAccessToken: string
let lagunaRefreshToken: string
+ let lagunaId: number
before(async function () {
this.timeout(30000)
const body = await server.users.getMyInfo({ token: lagunaAccessToken })
expect(body.username).to.equal('laguna')
- expect(body.account.displayName).to.equal('laguna')
+ expect(body.account.displayName).to.equal('Laguna Loire')
expect(body.role.id).to.equal(UserRole.USER)
+
+ lagunaId = body.id
}
})
expect(body.role.id).to.equal(UserRole.MODERATOR)
})
+ it('Should login Laguna and update the profile', async function () {
+ {
+ await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
+ await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
+
+ const body = await server.users.getMyInfo({ token: lagunaAccessToken })
+ expect(body.username).to.equal('laguna')
+ expect(body.account.displayName).to.equal('laguna updated')
+ expect(body.videoQuota).to.equal(43000)
+ expect(body.videoQuotaDaily).to.equal(43100)
+ }
+
+ {
+ const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
+ lagunaAccessToken = body.access_token
+ lagunaRefreshToken = body.refresh_token
+ }
+
+ {
+ const body = await server.users.getMyInfo({ token: lagunaAccessToken })
+ expect(body.username).to.equal('laguna')
+ expect(body.account.displayName).to.equal('Laguna Loire')
+ expect(body.videoQuota).to.equal(42000)
+ expect(body.videoQuotaDaily).to.equal(43100)
+ }
+ })
+
it('Should reject token of laguna by the plugin hook', async function () {
this.timeout(10000)
await server.servers.waitUntilLog('valid username')
await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
- await server.servers.waitUntilLog('valid display name')
+ await server.servers.waitUntilLog('valid displayName')
await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await server.servers.waitUntilLog('valid role')
await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
})
+ it('Should have the correct listening config', async function () {
+ const res = await makeGetRequest({
+ url: servers[0].url,
+ path: '/plugins/test-four/router/server-listening-config',
+ expectedStatus: HttpStatusCode.OK_200
+ })
+
+ expect(res.body.config).to.exist
+ expect(res.body.config.hostname).to.equal('::')
+ expect(res.body.config.port).to.equal(servers[0].port)
+ })
+
it('Should have the correct config', async function () {
const res = await makeGetRequest({
url: servers[0].url,
UserNotificationType
} from '@shared/models'
import {
+ ConfigCommand,
createMultipleServers,
doubleFollow,
PeerTubeServer,
await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
}
+// ---------------------------------------------------------------------------
+
async function checkUserRegistered (options: CheckerBaseParams & {
username: string
checkType: CheckerType
await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
}
+async function checkRegistrationRequest (options: CheckerBaseParams & {
+ username: string
+ registrationReason: string
+ checkType: CheckerType
+}) {
+ const { username, registrationReason } = options
+ const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST
+
+ function notificationChecker (notification: UserNotification, checkType: CheckerType) {
+ if (checkType === 'presence') {
+ expect(notification).to.not.be.undefined
+ expect(notification.type).to.equal(notificationType)
+
+ expect(notification.registration.username).to.equal(username)
+ } else {
+ expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username)
+ }
+ }
+
+ function emailNotificationFinder (email: object) {
+ const text: string = email['text']
+
+ return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason)
+ }
+
+ await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
+}
+
+// ---------------------------------------------------------------------------
+
async function checkNewActorFollow (options: CheckerBaseParams & {
followType: 'channel' | 'account'
followerName: string
const port = await MockSmtpServer.Instance.collectEmails(emails)
const overrideConfig = {
- smtp: {
- hostname: '127.0.0.1',
- port
- },
+ ...ConfigCommand.getEmailOverrideConfig(port),
+
signup: {
limit: 20
}
userAccessToken,
emails,
servers,
- channelId
+ channelId,
+ baseOverrideConfig: overrideConfig
}
}
checkNewAccountAbuseForModerators,
checkNewPeerTubeVersion,
checkNewPluginVersion,
- checkVideoStudioEditionIsFinished
+ checkVideoStudioEditionIsFinished,
+ checkRegistrationRequest
}
// ---------------------------------------------------------------------------
expect(video.name).to.equal(attributes.name)
expect(video.category.id).to.equal(attributes.category)
- expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc')
+ expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Unknown')
expect(video.licence.id).to.equal(attributes.licence)
expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
expect(video.language.id).to.equal(attributes.language)
-
import { OutgoingHttpHeaders } from 'http'
import { RegisterServerAuthExternalOptions } from '@server/types'
import {
MActorUrl,
MChannelBannerAccountDefault,
MChannelSyncChannel,
+ MRegistration,
MStreamingPlaylist,
+ MUserAccountUrl,
MVideoChangeOwnershipFull,
MVideoFile,
MVideoFormattableDetails,
actorFull?: MActorFull
user?: MUserDefault
+ userRegistration?: MRegistration
server?: MServer
actor: MActorAccountChannelId
}
+ videoFileToken?: {
+ user: MUserAccountUrl
+ }
+
authenticated?: boolean
registeredPlugin?: RegisteredPlugin
--- /dev/null
+type ObjectKeys<T> =
+ T extends object
+ ? `${Exclude<keyof T, symbol>}`[]
+ : T extends number
+ ? []
+ : T extends any | string
+ ? string[]
+ : never
+
+interface ObjectConstructor {
+ keys<T> (o: T): ObjectKeys<T>
+}
export * from './user'
export * from './user-notification'
export * from './user-notification-setting'
+export * from './user-registration'
export * from './user-video-history'
import { ApplicationModel } from '@server/models/application/application'
import { PluginModel } from '@server/models/server/plugin'
import { UserNotificationModel } from '@server/models/user/user-notification'
+import { UserRegistrationModel } from '@server/models/user/user-registration'
import { PickWith, PickWithOpt } from '@shared/typescript-utils'
import { AbuseModel } from '../../../models/abuse/abuse'
import { AccountModel } from '../../../models/account/account'
export type ApplicationInclude =
Pick<ApplicationModel, 'latestPeerTubeVersion'>
+
+ export type UserRegistrationInclude =
+ Pick<UserRegistrationModel, 'id' | 'username'>
}
// ############################################################################
export type MUserNotification =
Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
- 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'>
+ 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'>
// ############################################################################
Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
Use<'Plugin', UserNotificationIncludes.PluginInclude> &
Use<'Application', UserNotificationIncludes.ApplicationInclude> &
- Use<'Account', UserNotificationIncludes.AccountIncludeActor>
+ Use<'Account', UserNotificationIncludes.AccountIncludeActor> &
+ Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude>
--- /dev/null
+import { UserRegistrationModel } from '@server/models/user/user-registration'
+import { PickWith } from '@shared/typescript-utils'
+import { MUserId } from './user'
+
+type Use<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M>
+
+// ############################################################################
+
+export type MRegistration = Omit<UserRegistrationModel, 'User'>
+
+// ############################################################################
+
+export type MRegistrationFormattable =
+ MRegistration &
+ Use<'User', MUserId>
import express from 'express'
-import { UserRole } from '@shared/models'
+import { UserAdminFlag, UserRole } from '@shared/models'
import { MOAuthToken, MUser } from '../models'
export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
+export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
+
export interface RegisterServerAuthenticatedResult {
+ // Update the user profile if it already exists
+ // Default behaviour is no update
+ // Introduced in PeerTube >= 5.1
+ userUpdater?: <T> (options: {
+ fieldName: AuthenticatedResultUpdaterFieldName
+ currentValue: T
+ newValue: T
+ }) => T
+
username: string
email: string
role?: UserRole
displayName?: string
+
+ // PeerTube >= 5.1
+ adminFlags?: UserAdminFlag
+
+ // PeerTube >= 5.1
+ videoQuota?: number
+ // PeerTube >= 5.1
+ videoQuotaDaily?: number
}
export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
config: {
getWebserverUrl: () => string
+ // PeerTube >= 5.1
+ getServerListeningConfig: () => { hostname: string, port: number }
+
getServerConfig: () => Promise<ServerConfig>
}
-// Thanks https://stackoverflow.com/a/16187766
+// Thanks https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
function compareSemVer (a: string, b: string) {
- const regExStrip0 = /(\.0+)+$/
- const segmentsA = a.replace(regExStrip0, '').split('.')
- const segmentsB = b.replace(regExStrip0, '').split('.')
+ if (a.startsWith(b + '-')) return -1
+ if (b.startsWith(a + '-')) return 1
- const l = Math.min(segmentsA.length, segmentsB.length)
-
- for (let i = 0; i < l; i++) {
- const diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10)
-
- if (diff) return diff
- }
-
- return segmentsA.length - segmentsB.length
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })
}
export {
+import { RegisteredExternalAuthConfig } from '@shared/models'
import { HookType } from '../../models/plugins/hook-type.enum'
import { isCatchable, isPromise } from '../common/promises'
return result
}
+function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
+ return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+}
+
export {
getHookType,
- internalRunHook
+ internalRunHook,
+ getExternalAuthHref
}
...additionalAllowedTags,
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img'
],
- allowedSchemes: base.allowedSchemes,
+ allowedSchemes: [
+ ...base.allowedSchemes,
+
+ 'mailto'
+ ],
allowedAttributes: {
...base.allowedAttributes,
UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
UserRight.MANAGE_SERVERS_BLOCKLIST,
UserRight.MANAGE_USERS,
- UserRight.SEE_ALL_COMMENTS
+ UserRight.SEE_ALL_COMMENTS,
+ UserRight.MANAGE_REGISTRATIONS
],
[UserRole.USER]: []
// Filter result used to check if a user can register on the instance
'filter:api.user.signup.allowed.result': true,
+ // Filter result used to check if a user can send a registration request on the instance
+ // PeerTube >= 5.1
+ 'filter:api.user.request-signup.allowed.result': true,
+
// Filter result used to check if video/torrent download is allowed
'filter:api.download.video.allowed.result': true,
'filter:api.download.torrent.allowed.result': true,
'action:api.user.unblocked': true,
// Fired when a user registered on the instance
'action:api.user.registered': true,
+ // Fired when a user requested registration on the instance
+ // PeerTube >= 5.1
+ 'action:api.user.requested-registration': true,
// Fired when an admin/moderator created a user
'action:api.user.created': true,
// Fired when a user is removed by an admin/moderator
signup: {
enabled: boolean
limit: number
+ requiresApproval: boolean
requiresEmailVerification: boolean
minimumAge: number
}
allowed: boolean
allowedForCurrentIP: boolean
requiresEmailVerification: boolean
+ requiresApproval: boolean
minimumAge: number
}
*/
INCORRECT_FILES_IN_TORRENT = 'incorrect_files_in_torrent',
- COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video'
+ COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video',
+
+ MISSING_TWO_FACTOR = 'missing_two_factor',
+ INVALID_TWO_FACTOR = 'invalid_two_factor',
+
+ ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval',
+ ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected'
}
/**
*
* @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-token-error.js
*/
- INVALID_TOKEN = 'invalid_token',
+ INVALID_TOKEN = 'invalid_token'
}
+export * from './registration'
export * from './two-factor-enable-result.model'
export * from './user-create-result.model'
export * from './user-create.model'
export * from './user-notification-setting.model'
export * from './user-notification.model'
export * from './user-refresh-token.model'
-export * from './user-register.model'
export * from './user-right.enum'
export * from './user-role'
export * from './user-scoped-token'
--- /dev/null
+export * from './user-register.model'
+export * from './user-registration-request.model'
+export * from './user-registration-state.model'
+export * from './user-registration-update-state.model'
+export * from './user-registration.model'
--- /dev/null
+import { UserRegister } from './user-register.model'
+
+export interface UserRegistrationRequest extends UserRegister {
+ registrationReason: string
+}
--- /dev/null
+export const enum UserRegistrationState {
+ PENDING = 1,
+ REJECTED = 2,
+ ACCEPTED = 3
+}
--- /dev/null
+export interface UserRegistrationUpdateState {
+ moderationResponse: string
+ preventEmailDelivery?: boolean
+}
--- /dev/null
+import { UserRegistrationState } from './user-registration-state.model'
+
+export interface UserRegistration {
+ id: number
+
+ state: {
+ id: UserRegistrationState
+ label: string
+ }
+
+ registrationReason: string
+ moderationResponse: string
+
+ username: string
+ email: string
+ emailVerified: boolean
+
+ accountDisplayName: string
+
+ channelHandle: string
+ channelDisplayName: string
+
+ createdAt: Date
+ updatedAt: Date
+
+ user?: {
+ id: number
+ }
+}
NEW_PLUGIN_VERSION = 17,
NEW_PEERTUBE_VERSION = 18,
- MY_VIDEO_STUDIO_EDITION_FINISHED = 19
+ MY_VIDEO_STUDIO_EDITION_FINISHED = 19,
+
+ NEW_USER_REGISTRATION_REQUEST = 20
}
export interface VideoInfo {
latestVersion: string
}
+ registration?: {
+ id: number
+ username: string
+ }
+
createdAt: string
updatedAt: string
}
MANAGE_VIDEO_FILES = 25,
RUN_VIDEO_TRANSCODING = 26,
- MANAGE_VIDEO_IMPORTS = 27
+ MANAGE_VIDEO_IMPORTS = 27,
+
+ MANAGE_REGISTRATIONS = 28
}
return seq.query(`DELETE FROM "${table}"`, options)
}
- async getCount (table: string) {
- const seq = this.getSequelize()
-
- const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
-
- const [ { total } ] = await seq.query<{ total: string }>(`SELECT COUNT(*) as total FROM "${table}"`, options)
+ async getVideoShareCount () {
+ const [ { total } ] = await this.selectQuery<{ total: string }>(`SELECT COUNT(*) as total FROM "videoShare"`)
if (total === null) return 0
return parseInt(total, 10)
}
async getInternalFileUrl (fileId: number) {
- return this.selectQuery(`SELECT "fileUrl" FROM "videoFile" WHERE id = ${fileId}`)
- .then(rows => rows[0].fileUrl as string)
+ return this.selectQuery<{ fileUrl: string }>(`SELECT "fileUrl" FROM "videoFile" WHERE id = :fileId`, { fileId })
+ .then(rows => rows[0].fileUrl)
}
setActorField (to: string, field: string, value: string) {
- const seq = this.getSequelize()
-
- const options = { type: QueryTypes.UPDATE }
-
- return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options)
+ return this.updateQuery(`UPDATE actor SET ${this.escapeColumnName(field)} = :value WHERE url = :to`, { value, to })
}
setVideoField (uuid: string, field: string, value: string) {
- const seq = this.getSequelize()
-
- const options = { type: QueryTypes.UPDATE }
-
- return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
+ return this.updateQuery(`UPDATE video SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
}
setPlaylistField (uuid: string, field: string, value: string) {
- const seq = this.getSequelize()
-
- const options = { type: QueryTypes.UPDATE }
-
- return seq.query(`UPDATE "videoPlaylist" SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options)
+ return this.updateQuery(`UPDATE "videoPlaylist" SET ${this.escapeColumnName(field)} = :value WHERE uuid = :uuid`, { value, uuid })
}
async countVideoViewsOf (uuid: string) {
- const seq = this.getSequelize()
-
const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' +
- `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = '${uuid}'`
-
- const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
- const [ { total } ] = await seq.query<{ total: number }>(query, options)
+ `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid`
+ const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid })
if (!total) return 0
return forceNumber(total)
}
getActorImage (filename: string) {
- return this.selectQuery(`SELECT * FROM "actorImage" WHERE filename = '${filename}'`)
+ return this.selectQuery<{ width: number, height: number }>(`SELECT * FROM "actorImage" WHERE filename = :filename`, { filename })
.then(rows => rows[0])
}
- selectQuery (query: string) {
- const seq = this.getSequelize()
- const options = { type: QueryTypes.SELECT as QueryTypes.SELECT }
+ // ---------------------------------------------------------------------------
- return seq.query<any>(query, options)
+ setPluginVersion (pluginName: string, newVersion: string) {
+ return this.setPluginField(pluginName, 'version', newVersion)
}
- updateQuery (query: string) {
- const seq = this.getSequelize()
- const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE }
+ setPluginLatestVersion (pluginName: string, newVersion: string) {
+ return this.setPluginField(pluginName, 'latestVersion', newVersion)
+ }
- return seq.query(query, options)
+ setPluginField (pluginName: string, field: string, value: string) {
+ return this.updateQuery(
+ `UPDATE "plugin" SET ${this.escapeColumnName(field)} = :value WHERE "name" = :pluginName`,
+ { pluginName, value }
+ )
}
// ---------------------------------------------------------------------------
- setPluginField (pluginName: string, field: string, value: string) {
+ selectQuery <T extends object> (query: string, replacements: { [id: string]: string | number } = {}) {
const seq = this.getSequelize()
+ const options = {
+ type: QueryTypes.SELECT as QueryTypes.SELECT,
+ replacements
+ }
- const options = { type: QueryTypes.UPDATE }
-
- return seq.query(`UPDATE "plugin" SET "${field}" = '${value}' WHERE "name" = '${pluginName}'`, options)
+ return seq.query<T>(query, options)
}
- setPluginVersion (pluginName: string, newVersion: string) {
- return this.setPluginField(pluginName, 'version', newVersion)
- }
+ updateQuery (query: string, replacements: { [id: string]: string | number } = {}) {
+ const seq = this.getSequelize()
+ const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, replacements }
- setPluginLatestVersion (pluginName: string, newVersion: string) {
- return this.setPluginField(pluginName, 'latestVersion', newVersion)
+ return seq.query(query, options)
}
// ---------------------------------------------------------------------------
async getPlaylistInfohash (playlistId: number) {
- const result = await this.selectQuery('SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = ' + playlistId)
+ const query = 'SELECT "p2pMediaLoaderInfohashes" FROM "videoStreamingPlaylist" WHERE id = :playlistId'
+
+ const result = await this.selectQuery<{ p2pMediaLoaderInfohashes: string }>(query, { playlistId })
if (!result || result.length === 0) return []
return result[0].p2pMediaLoaderInfohashes
// ---------------------------------------------------------------------------
setActorFollowScores (newScore: number) {
- const seq = this.getSequelize()
-
- const options = { type: QueryTypes.UPDATE }
-
- return seq.query(`UPDATE "actorFollow" SET "score" = ${newScore}`, options)
+ return this.updateQuery(`UPDATE "actorFollow" SET "score" = :newScore`, { newScore })
}
setTokenField (accessToken: string, field: string, value: string) {
- const seq = this.getSequelize()
-
- const options = { type: QueryTypes.UPDATE }
-
- return seq.query(`UPDATE "oAuthToken" SET "${field}" = '${value}' WHERE "accessToken" = '${accessToken}'`, options)
+ return this.updateQuery(
+ `UPDATE "oAuthToken" SET ${this.escapeColumnName(field)} = :value WHERE "accessToken" = :accessToken`,
+ { value, accessToken }
+ )
}
async cleanup () {
return this.sequelize
}
+ private escapeColumnName (columnName: string) {
+ return this.getSequelize().escape(columnName)
+ .replace(/^'/, '"')
+ .replace(/'$/, '"')
+ }
}
return req.expect((res) => {
if (options.expectedStatus && res.status !== options.expectedStatus) {
throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
- `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` +
+ `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
'You may take a closer look at the logs. To see how to do so, check out this page: ' +
'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
}
}
}
+ // ---------------------------------------------------------------------------
+
+ static getEmailOverrideConfig (emailPort: number) {
+ return {
+ smtp: {
+ hostname: '127.0.0.1',
+ port: emailPort
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+
+ enableSignup (requiresApproval: boolean, limit = -1) {
+ return this.updateExistingSubConfig({
+ newConfig: {
+ signup: {
+ enabled: true,
+ requiresApproval,
+ limit
+ }
+ }
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+
disableImports () {
return this.setImportsEnabled(false)
}
})
}
+ // ---------------------------------------------------------------------------
+
+ enableChannelSync () {
+ return this.setChannelSyncEnabled(true)
+ }
+
+ disableChannelSync () {
+ return this.setChannelSyncEnabled(false)
+ }
+
private setChannelSyncEnabled (enabled: boolean) {
return this.updateExistingSubConfig({
newConfig: {
})
}
- enableChannelSync () {
- return this.setChannelSyncEnabled(true)
- }
-
- disableChannelSync () {
- return this.setChannelSyncEnabled(false)
- }
+ // ---------------------------------------------------------------------------
enableLive (options: {
allowReplay?: boolean
})
}
+ // ---------------------------------------------------------------------------
+
enableStudio () {
return this.updateExistingSubConfig({
newConfig: {
})
}
+ // ---------------------------------------------------------------------------
+
getConfig (options: OverrideCommandOptions = {}) {
const path = '/api/v1/config'
signup: {
enabled: false,
limit: 5,
+ requiresApproval: true,
requiresEmailVerification: false,
minimumAge: 16
},
BlocklistCommand,
LoginCommand,
NotificationsCommand,
+ RegistrationsCommand,
SubscriptionsCommand,
TwoFactorCommand,
UsersCommand
views?: ViewsCommand
twoFactor?: TwoFactorCommand
videoToken?: VideoTokenCommand
+ registrations?: RegistrationsCommand
constructor (options: { serverNumber: number } | { url: string }) {
if ((options as any).url) {
this.views = new ViewsCommand(this)
this.twoFactor = new TwoFactorCommand(this)
this.videoToken = new VideoTokenCommand(this)
+ this.registrations = new RegistrationsCommand(this)
}
}
export * from './login'
export * from './login-command'
export * from './notifications-command'
+export * from './registrations-command'
export * from './subscriptions-command'
export * from './two-factor-command'
export * from './users-command'
--- /dev/null
+import { pick } from '@shared/core-utils'
+import { HttpStatusCode, ResultList, UserRegistration, UserRegistrationRequest, UserRegistrationUpdateState } from '@shared/models'
+import { unwrapBody } from '../requests'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class RegistrationsCommand extends AbstractCommand {
+
+ register (options: OverrideCommandOptions & Partial<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username'>) {
+ 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<UserRegistrationRequest> & Pick<UserRegistrationRequest, 'username' | 'registrationReason'>
+ ) {
+ const { password = 'password', email = options.username + '@example.com' } = options
+ const path = '/api/v1/users/registrations/request'
+
+ return unwrapBody<UserRegistration>(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<ResultList<UserRegistration>>({
+ ...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
+ })
+ }
+}
return this.server.login.getAccessToken({ username, password })
}
- register (options: OverrideCommandOptions & {
- username: string
- password?: string
- displayName?: string
- email?: string
- channel?: {
- name: string
- displayName: string
- }
- }) {
- const { username, password = 'password', displayName, channel, email = username + '@example.com' } = options
- const path = '/api/v1/users/register'
-
- return this.postBodyRequest({
- ...options,
-
- path,
- fields: {
- username,
- password,
- email,
- displayName,
- channel
- },
- implicitToken: false,
- defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
- })
- }
-
// ---------------------------------------------------------------------------
getMyInfo (options: OverrideCommandOptions = {}) {
openapi: 3.0.0
info:
title: PeerTube
- version: 4.0.0
+ version: 5.1.0
contact:
name: PeerTube Community
url: https://joinpeertube.org
'200':
description: successful operation
- /api/v1/users/register:
+ /api/v1/users/ask-send-verify-email:
post:
- summary: Register a user
- operationId: registerUser
+ summary: Resend user verification link
+ operationId: resendEmailToVerifyUser
tags:
- Users
- Register
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ email:
+ type: string
+ description: User email
responses:
'204':
description: successful operation
+
+ /api/v1/users/registrations/ask-send-verify-email:
+ post:
+ summary: Resend verification link to registration email
+ operationId: resendEmailToVerifyRegistration
+ tags:
+ - Register
requestBody:
content:
application/json:
schema:
- $ref: '#/components/schemas/RegisterUser'
- required: true
+ type: object
+ properties:
+ email:
+ type: string
+ description: Registration email
+ responses:
+ '204':
+ description: successful operation
/api/v1/users/{id}/verify-email:
post:
description: |
Following a user registration, the new user will receive an email asking to click a link
containing a secret.
+ This endpoint can also be used to verify a new email set in the user account.
tags:
- Users
- Register
'404':
description: user not found
+ /api/v1/users/registrations/{registrationId}/verify-email:
+ post:
+ summary: Verify a registration email
+ operationId: verifyRegistrationEmail
+ description: |
+ Following a user registration request, the user will receive an email asking to click a link
+ containing a secret.
+ tags:
+ - Register
+ parameters:
+ - $ref: '#/components/parameters/registrationId'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ verificationString:
+ type: string
+ format: url
+ required:
+ - verificationString
+ responses:
+ '204':
+ description: successful operation
+ '403':
+ description: invalid verification string
+ '404':
+ description: registration not found
+
/api/v1/users/{id}/two-factor/request:
post:
summary: Request two factor auth
'404':
description: user not found
-
- /api/v1/users/ask-send-verify-email:
- post:
- summary: Resend user verification link
- operationId: resendEmailToVerifyUser
- tags:
- - Users
- - Register
- responses:
- '204':
- description: successful operation
-
/api/v1/users/me:
get:
summary: Get my user information
'204':
description: successful operation
+ /api/v1/users/register:
+ post:
+ summary: Register a user
+ operationId: registerUser
+ description: Signup has to be enabled and signup approval is not required
+ tags:
+ - Register
+ responses:
+ '204':
+ description: successful operation
+ '400':
+ description: request error
+ '403':
+ description: user registration is not enabled, user limit is reached, registration is not allowed for the ip, requires approval or blocked by a plugin
+ '409':
+ description: 'a user with this username, channel name or email already exists'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterUser'
+ required: true
+
+ /api/v1/users/registrations/request:
+ post:
+ summary: Request registration
+ description: Signup has to be enabled and require approval on the instance
+ operationId: requestRegistration
+ tags:
+ - Register
+ responses:
+ '200':
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserRegistration'
+ '400':
+ description: request error or signup approval is not enabled on the instance
+ '403':
+ description: user registration is not enabled, user limit is reached, registration is not allowed for the ip or blocked by a plugin
+ '409':
+ description: 'a user or registration with this username, channel name or email already exists'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserRegistrationRequest'
+
+ /api/v1/users/registrations/{registrationId}/accept:
+ post:
+ security:
+ - OAuth2:
+ - admin
+ - moderator
+ summary: Accept registration
+ operationId: acceptRegistration
+ tags:
+ - Register
+ parameters:
+ - $ref: '#/components/parameters/registrationId'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserRegistrationAcceptOrReject'
+ responses:
+ '204':
+ description: successful operation
+
+ /api/v1/users/registrations/{registrationId}/reject:
+ post:
+ security:
+ - OAuth2:
+ - admin
+ - moderator
+ summary: Reject registration
+ operationId: rejectRegistration
+ tags:
+ - Register
+ parameters:
+ - $ref: '#/components/parameters/registrationId'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserRegistrationAcceptOrReject'
+ responses:
+ '204':
+ description: successful operation
+
+ /api/v1/users/registrations/{registrationId}:
+ delete:
+ security:
+ - OAuth2:
+ - admin
+ - moderator
+ summary: Delete registration
+ description: 'Delete the registration entry. It will not remove the user associated with this registration (if any)'
+ operationId: deleteRegistration
+ tags:
+ - Register
+ parameters:
+ - $ref: '#/components/parameters/registrationId'
+ responses:
+ '204':
+ description: successful operation
+
+ /api/v1/users/registrations:
+ get:
+ security:
+ - OAuth2:
+ - admin
+ - moderator
+ summary: List registrations
+ operationId: listRegistrations
+ tags:
+ - Register
+ parameters:
+ - $ref: '#/components/parameters/start'
+ - $ref: '#/components/parameters/count'
+ - name: search
+ in: query
+ required: false
+ schema:
+ type: string
+ - name: sort
+ in: query
+ required: false
+ schema:
+ type: string
+ enum:
+ - -createdAt
+ - createdAt
+ - state
+ - -state
+ responses:
+ '204':
+ description: successful operation
+
/api/v1/videos/ownership:
get:
summary: List video ownership changes
type: string
enum:
- createdAt
+
name:
name: name
in: path
description: Entity id
schema:
$ref: '#/components/schemas/id'
+ registrationId:
+ name: registrationId
+ in: path
+ required: true
+ description: Registration ID
+ schema:
+ $ref: '#/components/schemas/id'
idOrUUID:
name: id
in: path
required:
- video
- rating
+
RegisterUser:
properties:
username:
- password
- email
+ UserRegistrationRequest:
+ allOf:
+ - $ref: '#/components/schemas/RegisterUser'
+ - type: object
+ properties:
+ registrationReason:
+ type: string
+ description: reason for the user to register on the instance
+ required:
+ - registrationReason
+
+ UserRegistrationAcceptOrReject:
+ type: object
+ properties:
+ moderationResponse:
+ type: string
+ description: Moderation response to send to the user
+ preventEmailDelivery:
+ type: boolean
+ description: Set it to true if you don't want PeerTube to send an email to the user
+ required:
+ - moderationResponse
+
+ UserRegistration:
+ properties:
+ id:
+ $ref: '#/components/schemas/id'
+ state:
+ type: object
+ properties:
+ id:
+ type: integer
+ enum:
+ - 1
+ - 2
+ - 3
+ description: 'The registration state (Pending = `1`, Rejected = `2`, Accepted = `3`)'
+ label:
+ type: string
+ registrationReason:
+ type: string
+ moderationResponse:
+ type: string
+ nullable: true
+ username:
+ type: string
+ email:
+ type: string
+ format: email
+ emailVerified:
+ type: boolean
+ accountDisplayName:
+ type: string
+ channelHandle:
+ type: string
+ channelDisplayName:
+ type: string
+ createdAt:
+ type: string
+ format: date-time
+ updatedAt:
+ type: string
+ format: date-time
+ user:
+ type: object
+ nullable: true
+ description: If the registration has been accepted, this is a partial user object created by the registration
+ properties:
+ id:
+ $ref: '#/components/schemas/id'
+
OAuthClient:
properties:
client_id:
:warning: **Warning**: dependencies guide is maintained by the community. Some parts may be outdated! :warning:
-Follow the below guides, and check their versions match [required external dependencies versions](https://github.com/Chocobozzz/PeerTube/blob/master/engines.yaml).
-
Main dependencies version supported by PeerTube:
* `node` >=14.x
## Upgrade
-**Important:** Before upgrading, check you have all the `storage` fields in your [production.yaml file](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/production.yaml).
+**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
Pull the latest images:
username: 'user'
email: 'user@example.com'
role: 2
- displayName: 'User display name'
+ displayName: 'User display name',
+
+ // Custom admin flags (bypass video auto moderation etc.)
+ // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts
+ // PeerTube >= 5.1
+ adminFlags: 0,
+ // Quota in bytes
+ // PeerTube >= 5.1
+ videoQuota: 1024 * 1024 * 1024, // 1GB
+ // PeerTube >= 5.1
+ videoQuotaDaily: -1, // Unlimited
+
+ // Update the user profile if it already exists
+ // Default behaviour is no update
+ // Introduced in PeerTube >= 5.1
+ userUpdater: ({ fieldName, currentValue, newValue }) => {
+ // Always use new value except for videoQuotaDaily field
+ if (fieldName === 'videoQuotaDaily') return currentValue
+
+ return newValue
+ }
})
})
If you plan to have many concurrent viewers on your PeerTube instance, consider increasing `worker_connections` value: https://nginx.org/en/docs/ngx_core_module.html#worker_connections.
-**FreeBSD**
+<details>
+<summary><strong>If using FreeBSD</strong></summary>
+
On FreeBSD you can use [Dehydrated](https://dehydrated.io/) `security/dehydrated` for [Let's Encrypt](https://letsencrypt.org/)
```bash
$ sudo pkg install dehydrated
```
+</details>
-### :alembic: TCP/IP Tuning
-
-**On Linux**
+### :alembic: Linux TCP/IP Tuning
```bash
$ sudo cp /var/www/peertube/peertube-latest/support/sysctl.d/30-peertube-tcp.conf /etc/sysctl.d/
$ sudo journalctl -feu peertube
```
-**FreeBSD**
+<details>
+<summary><strong>If using FreeBSD</strong></summary>
+
On FreeBSD, copy the startup script and update rc.conf:
```bash
```bash
$ sudo service peertube start
```
+</details>
-### :bricks: OpenRC
+<details>
+<summary><strong>If using OpenRC</strong></summary>
If your OS uses OpenRC, copy the service script:
$ sudo /etc/init.d/peertube start
$ tail -f /var/log/peertube/peertube.log
```
+</details>
### :technologist: Administrator
**Check the changelog (in particular the *IMPORTANT NOTES* section):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
-#### Auto
-
-The password it asks is PeerTube's database user password.
+Run the upgrade script (the password it asks is PeerTube's database user password):
```bash
$ cd /var/www/peertube/peertube-latest/scripts && sudo -H -u peertube ./upgrade.sh
$ sudo systemctl restart peertube # Or use your OS command to restart PeerTube if you don't use systemd
```
-#### Manually
+<details>
+<summary><strong>Prefer manual upgrade?</strong></summary>
Make a SQL backup
sudo unlink ./peertube-latest && \
sudo -u peertube ln -s versions/peertube-${VERSION} ./peertube-latest
```
+</details>
-### Configuration
+### Update PeerTube configuration
-You can check for configuration changes, and report them in your `config/production.yaml` file:
+Check for configuration changes, and report them in your `config/production.yaml` file:
```bash
$ cd /var/www/peertube/versions
$ diff -u "$(ls --sort=t | head -2 | tail -1)/config/production.yaml.example" "$(ls --sort=t | head -1)/config/production.yaml.example"
```
-### nginx
+### Update nginx configuration
Check changes in nginx configuration:
$ diff -u "$(ls --sort=t | head -2 | tail -1)/support/nginx/peertube" "$(ls --sort=t | head -1)/support/nginx/peertube"
```
-### systemd
+### Update systemd service
Check changes in systemd configuration:
"@shared/*": [ "shared/*" ]
},
"typeRoots": [
- "server/typings",
"node_modules/@types"
]
},
{ "path": "./server" },
{ "path": "./scripts" }
],
- "files": [ "server.ts", "server/types/express.d.ts" ]
+ "files": [ "server.ts", "server/types/express.d.ts", "server/types/lib.d.ts" ]
}
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
-typescript@^4.0.5:
+typescript@~4.8:
version "4.8.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==