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
"@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/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",
<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>
+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 { getExternalAuthHref } from '@shared/core-utils'
import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
@Component({
}
getAuthHref (auth: RegisteredExternalAuthConfig) {
- return PluginsManager.getExternalAuthHref(auth)
+ return getExternalAuthHref(environment.apiUrl, auth)
}
login () {
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
}
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
}
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'
}
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 () {
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 }) => {
+ console.log(data[0].name)
+
+ 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) {
}
.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),
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/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"
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)
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)
"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',
})
})
-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,
+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
}
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)
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(
'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',
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')),
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',
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,
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))
}
-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 { MOAuthClient } from '@server/types/models'
import { sha1 } from '@shared/extra-utils'
import { HttpStatusCode } from '@shared/models'
-import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
+import { OTP } from '../../initializers/constants'
import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
class MissingTwoFactorError extends Error {
*
*/
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')
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)
}
}
}
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)
--- /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,
USER_PASSWORD_CREATE_LIFETIME,
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 `views-${videoUUID}-${ip}`
}
- private generateTrackerBlockIPKey (ip: string) {
- return `tracker-block-ip-${ip}`
- }
-
private generateContactFormKey (ip: string) {
return 'contact-form-' + ip
}
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 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')
// ---------------------------------------------------------------------------
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 { 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 getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
+ const { direction, field } = buildSortDirectionAndField(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 } = 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,
+ getCommentSort,
+ 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
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'
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',
+++ /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)))
-}
--- /dev/null
+import { Model, Sequelize, Transaction } from 'sequelize'
+import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
+import { ActorImageType, VideoPrivacy } from '@shared/models'
+import { createSafeIn, getCommentSort, 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 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)
+ }
+
+ 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.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())
+ }
+
+ if (this.options.includeReplyCounters === true) {
+ toSelect.push(this.getTotalRepliesSelect())
+ toSelect.push(this.getAuthorTotalRepliesSelect())
+ }
+
+ 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()
+ ])
+ }
+
+ 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 getTotalRepliesSelect () {
+ const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
+
+ return `(` +
+ `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
+ `LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
+ `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
+ `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
+ `AND "deletedAt" IS NULL ` +
+ `AND ${blockWhereString} ` +
+ `) AS "totalReplies"`
+ }
+
+ private getAuthorTotalRepliesSelect () {
+ return `(` +
+ `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"` +
+ `) AS "totalRepliesFromVideoAuthor"`
+ }
+
+ private getOrder () {
+ if (!this.options.sort) return ''
+
+ const orders = getCommentSort(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'
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 })
}
// ---------------------------------------------------------------------------
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}`)
// ---------------------------------------------------------------
before(async function () {
- this.timeout(80000)
+ this.timeout(160000)
servers = await createMultipleServers(2)
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
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)
})
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)
// ---------------------------------------------------------------------------
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 ])
+import './oauth'
import './two-factor'
import './user-subscriptions'
import './user-videos'
--- /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 ])
+ })
+})
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('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
})
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(data[0].text).to.equal('my second answer to thread 4')
+ 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(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 () {
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.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 { 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,
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,
-
import { OutgoingHttpHeaders } from 'http'
import { RegisterServerAuthExternalOptions } from '@server/types'
import {
MChannelBannerAccountDefault,
MChannelSyncChannel,
MStreamingPlaylist,
+ MUserAccountUrl,
MVideoChangeOwnershipFull,
MVideoFile,
MVideoFormattableDetails,
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>
+}
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>
}
+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
}
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')
}
: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==