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 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(
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))
}
RegisterServerExternalAuthenticatedResult
} from '@server/types/plugins/register-server-auth.model'
import { UserAdminFlag, UserRole } from '@shared/models'
+import { BypassLogin } from './oauth-model'
export type ExternalUser =
Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
const authBypassTokens = new Map<string, {
expires: Date
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
}
}
if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
+ 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
+ }
+
return true
}
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 { AttributesOnly } from '@shared/typescript-utils'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { OAuthClientModel } from '../../models/oauth/oauth-client'
pluginName: string
authName?: string
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.')
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.')
}
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 })
displayName: 'Kefka Palazzo',
adminFlags: 1,
videoQuota: 42000,
- videoQuotaDaily: 42100
+ videoQuotaDaily: 42100,
+
+ // Always use new value except for videoQuotaDaily field
+ userUpdater: ({ fieldName, currentValue, newValue }) => {
+ if (fieldName === 'videoQuotaDaily') return currentValue
+
+ return newValue
+ }
})
},
hookTokenValidity: (options) => {
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
+ }
})
}
let kefkaAccessToken: string
let kefkaRefreshToken: string
+ let kefkaId: number
let externalAuthToken: string
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,
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')
-
import { OutgoingHttpHeaders } from 'http'
import { RegisterServerAuthExternalOptions } from '@server/types'
import {
--- /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 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
}
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
+ }
})
})