aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.travis.yml6
-rw-r--r--client/src/app/app.module.ts2
-rw-r--r--client/src/app/header/header.component.ts2
-rw-r--r--client/src/app/search/index.ts3
-rw-r--r--client/src/app/search/search-routing.module.ts23
-rw-r--r--client/src/app/search/search.component.html19
-rw-r--r--client/src/app/search/search.component.scss93
-rw-r--r--client/src/app/search/search.component.ts93
-rw-r--r--client/src/app/search/search.module.ts25
-rw-r--r--client/src/app/search/search.service.ts46
-rw-r--r--client/src/app/shared/shared.module.ts9
-rw-r--r--client/src/app/shared/video/video-miniature.component.html2
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html2
-rw-r--r--client/src/app/shared/video/video.service.ts51
-rw-r--r--client/src/app/videos/video-list/index.ts2
-rw-r--r--client/src/app/videos/video-list/video-search.component.ts77
-rw-r--r--client/src/app/videos/videos-routing.module.ts12
-rw-r--r--client/src/app/videos/videos.module.ts4
-rwxr-xr-xscripts/clean/server/test.sh8
-rw-r--r--server.ts2
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/search.ts43
-rw-r--r--server/controllers/api/videos/index.ts23
-rw-r--r--server/controllers/client.ts10
-rw-r--r--server/helpers/database-utils.ts2
-rw-r--r--server/initializers/constants.ts4
-rw-r--r--server/initializers/database.ts43
-rw-r--r--server/middlewares/sort.ts7
-rw-r--r--server/middlewares/validators/index.ts1
-rw-r--r--server/middlewares/validators/search.ts22
-rw-r--r--server/middlewares/validators/sort.ts3
-rw-r--r--server/middlewares/validators/videos.ts15
-rw-r--r--server/models/activitypub/actor.ts6
-rw-r--r--server/models/utils.ts52
-rw-r--r--server/models/video/video.ts92
-rw-r--r--server/tests/utils/videos/videos.ts12
-rw-r--r--support/doc/docker.md4
-rw-r--r--support/doc/production.md9
38 files changed, 584 insertions, 247 deletions
diff --git a/.travis.yml b/.travis.yml
index 51892e504..042719529 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -29,12 +29,6 @@ before_script:
29 - cp ffmpeg-*-64bit-static/{ffmpeg,ffprobe,ffserver} $HOME/bin 29 - cp ffmpeg-*-64bit-static/{ffmpeg,ffprobe,ffserver} $HOME/bin
30 - export PATH=$HOME/bin:$PATH 30 - export PATH=$HOME/bin:$PATH
31 - export NODE_TEST_IMAGE=true 31 - export NODE_TEST_IMAGE=true
32 - psql -c 'create database peertube_test1;' -U postgres
33 - psql -c 'create database peertube_test2;' -U postgres
34 - psql -c 'create database peertube_test3;' -U postgres
35 - psql -c 'create database peertube_test4;' -U postgres
36 - psql -c 'create database peertube_test5;' -U postgres
37 - psql -c 'create database peertube_test6;' -U postgres
38 - psql -c "create user peertube with password 'peertube';" -U postgres 32 - psql -c "create user peertube with password 'peertube';" -U postgres
39 33
40matrix: 34matrix:
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 48886fd4e..9d655c523 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -18,6 +18,7 @@ import { VideosModule } from './videos'
18import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n' 18import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
19import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 19import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
20import { LanguageChooserComponent } from '@app/menu/language-chooser.component' 20import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
21import { SearchModule } from '@app/search'
21 22
22export function metaFactory (serverService: ServerService): MetaLoader { 23export function metaFactory (serverService: ServerService): MetaLoader {
23 return new MetaStaticLoader({ 24 return new MetaStaticLoader({
@@ -52,6 +53,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
52 LoginModule, 53 LoginModule,
53 ResetPasswordModule, 54 ResetPasswordModule,
54 SignupModule, 55 SignupModule,
56 SearchModule,
55 SharedModule, 57 SharedModule,
56 VideosModule, 58 VideosModule,
57 59
diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts
index 0e999fbb1..f73d40947 100644
--- a/client/src/app/header/header.component.ts
+++ b/client/src/app/header/header.component.ts
@@ -24,7 +24,7 @@ export class HeaderComponent implements OnInit {
24 } 24 }
25 25
26 doSearch () { 26 doSearch () {
27 this.router.navigate([ '/videos', 'search' ], { 27 this.router.navigate([ '/search' ], {
28 queryParams: { search: this.searchValue } 28 queryParams: { search: this.searchValue }
29 }) 29 })
30 } 30 }
diff --git a/client/src/app/search/index.ts b/client/src/app/search/index.ts
new file mode 100644
index 000000000..40f4e021f
--- /dev/null
+++ b/client/src/app/search/index.ts
@@ -0,0 +1,3 @@
1export * from './search-routing.module'
2export * from './search.component'
3export * from './search.module'
diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts
new file mode 100644
index 000000000..0ac9e6b57
--- /dev/null
+++ b/client/src/app/search/search-routing.module.ts
@@ -0,0 +1,23 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { SearchComponent } from '@app/search/search.component'
5
6const searchRoutes: Routes = [
7 {
8 path: 'search',
9 component: SearchComponent,
10 canActivate: [ MetaGuard ],
11 data: {
12 meta: {
13 title: 'Search'
14 }
15 }
16 }
17]
18
19@NgModule({
20 imports: [ RouterModule.forChild(searchRoutes) ],
21 exports: [ RouterModule ]
22})
23export class SearchRoutingModule {}
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
new file mode 100644
index 000000000..b8c4d7dc5
--- /dev/null
+++ b/client/src/app/search/search.component.html
@@ -0,0 +1,19 @@
1<div i18n *ngIf="pagination.totalItems === 0" class="no-result">
2 No results found
3</div>
4
5<div myInfiniteScroller [autoLoading]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
6 <div i18n *ngIf="pagination.totalItems" class="results-counter">
7 {{ pagination.totalItems | myNumberFormatter }} results for <span class="search-value">{{ currentSearch }}</span>
8 </div>
9
10 <div *ngFor="let video of videos" class="entry video">
11 <my-video-thumbnail [video]="video"></my-video-thumbnail>
12
13 <div class="video-info">
14 <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
15 <span i18n class="video-info-date-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>
16 <a class="video-info-account" [routerLink]="[ '/accounts', video.by ]">{{ video.by }}</a>
17 </div>
18 </div>
19</div>
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
new file mode 100644
index 000000000..06e3c9542
--- /dev/null
+++ b/client/src/app/search/search.component.scss
@@ -0,0 +1,93 @@
1@import '_variables';
2@import '_mixins';
3
4.no-result {
5 height: 70vh;
6 display: flex;
7 align-items: center;
8 justify-content: center;
9 font-size: 16px;
10 font-weight: $font-semibold;
11}
12
13.search-result {
14 margin-left: 40px;
15 margin-top: 40px;
16
17 .results-counter {
18 font-size: 15px;
19 padding-bottom: 20px;
20 margin-bottom: 30px;
21 border-bottom: 1px solid #DADADA;
22
23 .search-value {
24 font-weight: $font-semibold;
25 }
26 }
27
28 .entry {
29 display: flex;
30 min-height: 130px;
31 padding-bottom: 20px;
32 margin-bottom: 20px;
33
34 &.video {
35
36 my-video-thumbnail {
37 margin-right: 10px;
38 }
39
40 .video-info {
41 flex-grow: 1;
42
43 .video-info-name {
44 @include disable-default-a-behaviour;
45
46 color: #000;
47 display: block;
48 width: fit-content;
49 font-size: 18px;
50 font-weight: $font-semibold;
51 }
52
53 .video-info-date-views {
54 font-size: 14px;
55 }
56
57 .video-info-account {
58 @include disable-default-a-behaviour;
59
60 display: block;
61 width: fit-content;
62 overflow: hidden;
63 text-overflow: ellipsis;
64 white-space: nowrap;
65 font-size: 14px;
66 color: #585858;
67
68 &:hover {
69 color: #303030;
70 }
71 }
72 }
73 }
74 }
75}
76
77@media screen and (max-width: 800px) {
78 .entry {
79 flex-direction: column;
80 height: auto;
81 text-align: center;
82
83 &.video {
84 .video-info-name {
85 margin: auto;
86 }
87
88 my-video-thumbnail {
89 margin-right: 0;
90 }
91 }
92 }
93}
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
new file mode 100644
index 000000000..be1cb3689
--- /dev/null
+++ b/client/src/app/search/search.component.ts
@@ -0,0 +1,93 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute } from '@angular/router'
3import { RedirectService } from '@app/core'
4import { NotificationsService } from 'angular2-notifications'
5import { Subscription } from 'rxjs'
6import { SearchService } from '@app/search/search.service'
7import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { Video } from '../../../../shared'
10import { MetaService } from '@ngx-meta/core'
11
12@Component({
13 selector: 'my-search',
14 styleUrls: [ './search.component.scss' ],
15 templateUrl: './search.component.html'
16})
17export class SearchComponent implements OnInit, OnDestroy {
18 videos: Video[] = []
19 pagination: ComponentPagination = {
20 currentPage: 1,
21 itemsPerPage: 10, // It's per object type (so 10 videos, 10 video channels etc)
22 totalItems: null
23 }
24
25 private subActivatedRoute: Subscription
26 private currentSearch: string
27
28 constructor (
29 private i18n: I18n,
30 private route: ActivatedRoute,
31 private metaService: MetaService,
32 private redirectService: RedirectService,
33 private notificationsService: NotificationsService,
34 private searchService: SearchService
35 ) { }
36
37 ngOnInit () {
38 this.subActivatedRoute = this.route.queryParams.subscribe(
39 queryParams => {
40 const querySearch = queryParams['search']
41
42 if (!querySearch) return this.redirectService.redirectToHomepage()
43 if (querySearch === this.currentSearch) return
44
45 this.currentSearch = querySearch
46 this.updateTitle()
47
48 this.reload()
49 },
50
51 err => this.notificationsService.error('Error', err.text)
52 )
53 }
54
55 ngOnDestroy () {
56 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
57 }
58
59 search () {
60 return this.searchService.searchVideos(this.currentSearch, this.pagination)
61 .subscribe(
62 ({ videos, totalVideos }) => {
63 this.videos = this.videos.concat(videos)
64 this.pagination.totalItems = totalVideos
65 },
66
67 error => {
68 this.notificationsService.error(this.i18n('Error'), error.message)
69 }
70 )
71 }
72
73 onNearOfBottom () {
74 // Last page
75 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
76
77 this.pagination.currentPage += 1
78 this.search()
79 }
80
81 private reload () {
82 this.pagination.currentPage = 1
83 this.pagination.totalItems = null
84
85 this.videos = []
86
87 this.search()
88 }
89
90 private updateTitle () {
91 this.metaService.setTitle(this.i18n('Search') + ' ' + this.currentSearch)
92 }
93}
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
new file mode 100644
index 000000000..c6ec74d20
--- /dev/null
+++ b/client/src/app/search/search.module.ts
@@ -0,0 +1,25 @@
1import { NgModule } from '@angular/core'
2import { SharedModule } from '../shared'
3import { SearchComponent } from '@app/search/search.component'
4import { SearchService } from '@app/search/search.service'
5import { SearchRoutingModule } from '@app/search/search-routing.module'
6
7@NgModule({
8 imports: [
9 SearchRoutingModule,
10 SharedModule
11 ],
12
13 declarations: [
14 SearchComponent
15 ],
16
17 exports: [
18 SearchComponent
19 ],
20
21 providers: [
22 SearchService
23 ]
24})
25export class SearchModule { }
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
new file mode 100644
index 000000000..02d5f5915
--- /dev/null
+++ b/client/src/app/search/search.service.ts
@@ -0,0 +1,46 @@
1import { catchError, switchMap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
6import { VideoService } from '@app/shared/video/video.service'
7import { RestExtractor, RestService } from '@app/shared'
8import { environment } from 'environments/environment'
9import { ResultList, Video } from '../../../../shared'
10import { Video as VideoServerModel } from '@app/shared/video/video.model'
11
12export type SearchResult = {
13 videosResult: { totalVideos: number, videos: Video[] }
14}
15
16@Injectable()
17export class SearchService {
18 static BASE_SEARCH_URL = environment.apiUrl + '/api/v1/search/'
19
20 constructor (
21 private authHttp: HttpClient,
22 private restExtractor: RestExtractor,
23 private restService: RestService,
24 private videoService: VideoService
25 ) {}
26
27 searchVideos (
28 search: string,
29 componentPagination: ComponentPagination
30 ): Observable<{ videos: Video[], totalVideos: number }> {
31 const url = SearchService.BASE_SEARCH_URL + 'videos'
32
33 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
34
35 let params = new HttpParams()
36 params = this.restService.addRestGetParams(params, pagination)
37 params = params.append('search', search)
38
39 return this.authHttp
40 .get<ResultList<VideoServerModel>>(url, { params })
41 .pipe(
42 switchMap(res => this.videoService.extractVideos(res)),
43 catchError(err => this.restExtractor.handleError(err))
44 )
45 }
46}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index fdfb90600..99df61cdb 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -37,9 +37,14 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
37import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 37import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
38import { 38import {
39 CustomConfigValidatorsService, 39 CustomConfigValidatorsService,
40 LoginValidatorsService, ReactiveFileComponent, 40 LoginValidatorsService,
41 ReactiveFileComponent,
41 ResetPasswordValidatorsService, 42 ResetPasswordValidatorsService,
42 UserValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, VideoValidatorsService 43 UserValidatorsService,
44 VideoAbuseValidatorsService,
45 VideoChannelValidatorsService,
46 VideoCommentValidatorsService,
47 VideoValidatorsService
43} from '@app/shared/forms' 48} from '@app/shared/forms'
44import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 49import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
45import { ScreenService } from '@app/shared/misc/screen.service' 50import { ScreenService } from '@app/shared/misc/screen.service'
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index 20020e2a8..3010e5ccc 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -3,7 +3,7 @@
3 3
4 <div class="video-miniature-information"> 4 <div class="video-miniature-information">
5 <a 5 <a
6 class="video-miniature-name" alt="" 6 class="video-miniature-name"
7 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }" 7 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
8 > 8 >
9 {{ video.name }} 9 {{ video.name }}
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index 971f352ba..4909cf3f1 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -2,7 +2,7 @@
2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" 2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5<img [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5<img alt="" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
6 6
7<div class="video-thumbnail-overlay"> 7<div class="video-thumbnail-overlay">
8 {{ video.durationLabel }} 8 {{ video.durationLabel }}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index b4c1f10f9..f316d31ea 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -231,27 +231,6 @@ export class VideoService {
231 return this.buildBaseFeedUrls(params) 231 return this.buildBaseFeedUrls(params)
232 } 232 }
233 233
234 searchVideos (
235 search: string,
236 videoPagination: ComponentPagination,
237 sort: VideoSortField
238 ): Observable<{ videos: Video[], totalVideos: number }> {
239 const url = VideoService.BASE_VIDEO_URL + 'search'
240
241 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
242
243 let params = new HttpParams()
244 params = this.restService.addRestGetParams(params, pagination, sort)
245 params = params.append('search', search)
246
247 return this.authHttp
248 .get<ResultList<VideoServerModel>>(url, { params })
249 .pipe(
250 switchMap(res => this.extractVideos(res)),
251 catchError(err => this.restExtractor.handleError(err))
252 )
253 }
254
255 removeVideo (id: number) { 234 removeVideo (id: number) {
256 return this.authHttp 235 return this.authHttp
257 .delete(VideoService.BASE_VIDEO_URL + id) 236 .delete(VideoService.BASE_VIDEO_URL + id)
@@ -289,21 +268,7 @@ export class VideoService {
289 .pipe(catchError(err => this.restExtractor.handleError(err))) 268 .pipe(catchError(err => this.restExtractor.handleError(err)))
290 } 269 }
291 270
292 private setVideoRate (id: number, rateType: VideoRateType) { 271 extractVideos (result: ResultList<VideoServerModel>) {
293 const url = VideoService.BASE_VIDEO_URL + id + '/rate'
294 const body: UserVideoRateUpdate = {
295 rating: rateType
296 }
297
298 return this.authHttp
299 .put(url, body)
300 .pipe(
301 map(this.restExtractor.extractDataBool),
302 catchError(err => this.restExtractor.handleError(err))
303 )
304 }
305
306 private extractVideos (result: ResultList<VideoServerModel>) {
307 return this.serverService.localeObservable 272 return this.serverService.localeObservable
308 .pipe( 273 .pipe(
309 map(translations => { 274 map(translations => {
@@ -319,4 +284,18 @@ export class VideoService {
319 }) 284 })
320 ) 285 )
321 } 286 }
287
288 private setVideoRate (id: number, rateType: VideoRateType) {
289 const url = VideoService.BASE_VIDEO_URL + id + '/rate'
290 const body: UserVideoRateUpdate = {
291 rating: rateType
292 }
293
294 return this.authHttp
295 .put(url, body)
296 .pipe(
297 map(this.restExtractor.extractDataBool),
298 catchError(err => this.restExtractor.handleError(err))
299 )
300 }
322} 301}
diff --git a/client/src/app/videos/video-list/index.ts b/client/src/app/videos/video-list/index.ts
index 5e7c7886c..5f7c8bd48 100644
--- a/client/src/app/videos/video-list/index.ts
+++ b/client/src/app/videos/video-list/index.ts
@@ -1,3 +1,3 @@
1export * from './video-local.component'
1export * from './video-recently-added.component' 2export * from './video-recently-added.component'
2export * from './video-trending.component' 3export * from './video-trending.component'
3export * from './video-search.component'
diff --git a/client/src/app/videos/video-list/video-search.component.ts b/client/src/app/videos/video-list/video-search.component.ts
deleted file mode 100644
index 33ed3f00e..000000000
--- a/client/src/app/videos/video-list/video-search.component.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { Location } from '@angular/common'
4import { RedirectService } from '@app/core'
5import { immutableAssign } from '@app/shared/misc/utils'
6import { NotificationsService } from 'angular2-notifications'
7import { Subscription } from 'rxjs'
8import { AuthService } from '../../core/auth'
9import { AbstractVideoList } from '../../shared/video/abstract-video-list'
10import { VideoService } from '../../shared/video/video.service'
11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { ScreenService } from '@app/shared/misc/screen.service'
13
14@Component({
15 selector: 'my-videos-search',
16 styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
17 templateUrl: '../../shared/video/abstract-video-list.html'
18})
19export class VideoSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
20 titlePage: string
21 currentRoute = '/videos/search'
22 loadOnInit = false
23
24 protected otherRouteParams = {
25 search: ''
26 }
27 private subActivatedRoute: Subscription
28
29 constructor (
30 protected router: Router,
31 protected route: ActivatedRoute,
32 protected notificationsService: NotificationsService,
33 protected authService: AuthService,
34 protected location: Location,
35 protected i18n: I18n,
36 protected screenService: ScreenService,
37 private videoService: VideoService,
38 private redirectService: RedirectService
39 ) {
40 super()
41
42 this.titlePage = i18n('Search')
43 }
44
45 ngOnInit () {
46 super.ngOnInit()
47
48 this.subActivatedRoute = this.route.queryParams.subscribe(
49 queryParams => {
50 const querySearch = queryParams['search']
51
52 if (!querySearch) return this.redirectService.redirectToHomepage()
53 if (this.otherRouteParams.search === querySearch) return
54
55 this.otherRouteParams.search = querySearch
56 this.reloadVideos()
57 },
58
59 err => this.notificationsService.error('Error', err.text)
60 )
61 }
62
63 ngOnDestroy () {
64 super.ngOnDestroy()
65
66 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
67 }
68
69 getVideosObservable (page: number) {
70 const newPagination = immutableAssign(this.pagination, { currentPage: page })
71 return this.videoService.searchVideos(this.otherRouteParams.search, newPagination, this.sort)
72 }
73
74 generateSyndicationList () {
75 throw new Error('Search does not support syndication.')
76 }
77}
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts
index da786c0f9..538a43c6d 100644
--- a/client/src/app/videos/videos-routing.module.ts
+++ b/client/src/app/videos/videos-routing.module.ts
@@ -1,8 +1,7 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes, UrlSegment } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { VideoLocalComponent } from '@app/videos/video-list/video-local.component' 3import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
4import { MetaGuard } from '@ngx-meta/core' 4import { MetaGuard } from '@ngx-meta/core'
5import { VideoSearchComponent } from './video-list'
6import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' 5import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
7import { VideoTrendingComponent } from './video-list/video-trending.component' 6import { VideoTrendingComponent } from './video-list/video-trending.component'
8import { VideosComponent } from './videos.component' 7import { VideosComponent } from './videos.component'
@@ -46,15 +45,6 @@ const videosRoutes: Routes = [
46 } 45 }
47 }, 46 },
48 { 47 {
49 path: 'search',
50 component: VideoSearchComponent,
51 data: {
52 meta: {
53 title: 'Search videos'
54 }
55 }
56 },
57 {
58 path: 'upload', 48 path: 'upload',
59 loadChildren: 'app/videos/+video-edit/video-add.module#VideoAddModule', 49 loadChildren: 'app/videos/+video-edit/video-add.module#VideoAddModule',
60 data: { 50 data: {
diff --git a/client/src/app/videos/videos.module.ts b/client/src/app/videos/videos.module.ts
index 7c3d457b3..c38257e08 100644
--- a/client/src/app/videos/videos.module.ts
+++ b/client/src/app/videos/videos.module.ts
@@ -1,7 +1,6 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { VideoLocalComponent } from '@app/videos/video-list/video-local.component' 2import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
3import { SharedModule } from '../shared' 3import { SharedModule } from '../shared'
4import { VideoSearchComponent } from './video-list'
5import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' 4import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
6import { VideoTrendingComponent } from './video-list/video-trending.component' 5import { VideoTrendingComponent } from './video-list/video-trending.component'
7import { VideosRoutingModule } from './videos-routing.module' 6import { VideosRoutingModule } from './videos-routing.module'
@@ -18,8 +17,7 @@ import { VideosComponent } from './videos.component'
18 17
19 VideoTrendingComponent, 18 VideoTrendingComponent,
20 VideoRecentlyAddedComponent, 19 VideoRecentlyAddedComponent,
21 VideoLocalComponent, 20 VideoLocalComponent
22 VideoSearchComponent
23 ], 21 ],
24 22
25 exports: [ 23 exports: [
diff --git a/scripts/clean/server/test.sh b/scripts/clean/server/test.sh
index 42651d3a8..3b8fe39ed 100755
--- a/scripts/clean/server/test.sh
+++ b/scripts/clean/server/test.sh
@@ -3,10 +3,14 @@
3set -eu 3set -eu
4 4
5for i in $(seq 1 6); do 5for i in $(seq 1 6); do
6 dropdb --if-exists "peertube_test$i" 6 dbname="peertube_test$i"
7
8 dropdb --if-exists "$dbname"
7 rm -rf "./test$i" 9 rm -rf "./test$i"
8 rm -f "./config/local-test.json" 10 rm -f "./config/local-test.json"
9 rm -f "./config/local-test-$i.json" 11 rm -f "./config/local-test-$i.json"
10 createdb -O peertube "peertube_test$i" 12 createdb -O peertube "$dbname"
13 psql -c "CREATE EXTENSION pg_trgm;" "$dbname"
14 psql -c "CREATE EXTENSION unaccent;" "$dbname"
11 redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL 15 redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
12done 16done
diff --git a/server.ts b/server.ts
index 104de2153..1bfec724b 100644
--- a/server.ts
+++ b/server.ts
@@ -49,7 +49,7 @@ if (errorMessage !== null) {
49// Trust our proxy (IP forwarding...) 49// Trust our proxy (IP forwarding...)
50app.set('trust proxy', CONFIG.TRUST_PROXY) 50app.set('trust proxy', CONFIG.TRUST_PROXY)
51 51
52// Security middlewares 52// Security middleware
53app.use(helmet({ 53app.use(helmet({
54 frameguard: { 54 frameguard: {
55 action: 'deny' // we only allow it for /videos/embed, see server/controllers/client.ts 55 action: 'deny' // we only allow it for /videos/embed, see server/controllers/client.ts
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index c386a6710..e928a7478 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -9,6 +9,7 @@ import { videosRouter } from './videos'
9import { badRequest } from '../../helpers/express-utils' 9import { badRequest } from '../../helpers/express-utils'
10import { videoChannelRouter } from './video-channel' 10import { videoChannelRouter } from './video-channel'
11import * as cors from 'cors' 11import * as cors from 'cors'
12import { searchRouter } from './search'
12 13
13const apiRouter = express.Router() 14const apiRouter = express.Router()
14 15
@@ -26,6 +27,7 @@ apiRouter.use('/accounts', accountsRouter)
26apiRouter.use('/video-channels', videoChannelRouter) 27apiRouter.use('/video-channels', videoChannelRouter)
27apiRouter.use('/videos', videosRouter) 28apiRouter.use('/videos', videosRouter)
28apiRouter.use('/jobs', jobsRouter) 29apiRouter.use('/jobs', jobsRouter)
30apiRouter.use('/search', searchRouter)
29apiRouter.use('/ping', pong) 31apiRouter.use('/ping', pong)
30apiRouter.use('/*', badRequest) 32apiRouter.use('/*', badRequest)
31 33
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
new file mode 100644
index 000000000..2ff340b59
--- /dev/null
+++ b/server/controllers/api/search.ts
@@ -0,0 +1,43 @@
1import * as express from 'express'
2import { isNSFWHidden } from '../../helpers/express-utils'
3import { getFormattedObjects } from '../../helpers/utils'
4import { VideoModel } from '../../models/video/video'
5import {
6 asyncMiddleware,
7 optionalAuthenticate,
8 paginationValidator,
9 searchValidator,
10 setDefaultPagination,
11 setDefaultSearchSort,
12 videosSearchSortValidator
13} from '../../middlewares'
14
15const searchRouter = express.Router()
16
17searchRouter.get('/videos',
18 paginationValidator,
19 setDefaultPagination,
20 videosSearchSortValidator,
21 setDefaultSearchSort,
22 optionalAuthenticate,
23 searchValidator,
24 asyncMiddleware(searchVideos)
25)
26
27// ---------------------------------------------------------------------------
28
29export { searchRouter }
30
31// ---------------------------------------------------------------------------
32
33async function searchVideos (req: express.Request, res: express.Response) {
34 const resultList = await VideoModel.searchAndPopulateAccountAndServer(
35 req.query.search as string,
36 req.query.start as number,
37 req.query.count as number,
38 req.query.sort as string,
39 isNSFWHidden(res)
40 )
41
42 return res.json(getFormattedObjects(resultList.data, resultList.total))
43}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index bbb5b8b4c..547522123 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -38,7 +38,6 @@ import {
38 videosAddValidator, 38 videosAddValidator,
39 videosGetValidator, 39 videosGetValidator,
40 videosRemoveValidator, 40 videosRemoveValidator,
41 videosSearchValidator,
42 videosSortValidator, 41 videosSortValidator,
43 videosUpdateValidator 42 videosUpdateValidator
44} from '../../../middlewares' 43} from '../../../middlewares'
@@ -50,7 +49,6 @@ import { blacklistRouter } from './blacklist'
50import { videoCommentRouter } from './comment' 49import { videoCommentRouter } from './comment'
51import { rateVideoRouter } from './rate' 50import { rateVideoRouter } from './rate'
52import { VideoFilter } from '../../../../shared/models/videos/video-query.type' 51import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' 52import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 53import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
56import { videoCaptionsRouter } from './captions' 54import { videoCaptionsRouter } from './captions'
@@ -94,15 +92,6 @@ videosRouter.get('/',
94 optionalAuthenticate, 92 optionalAuthenticate,
95 asyncMiddleware(listVideos) 93 asyncMiddleware(listVideos)
96) 94)
97videosRouter.get('/search',
98 videosSearchValidator,
99 paginationValidator,
100 videosSortValidator,
101 setDefaultSort,
102 setDefaultPagination,
103 optionalAuthenticate,
104 asyncMiddleware(searchVideos)
105)
106videosRouter.put('/:id', 95videosRouter.put('/:id',
107 authenticate, 96 authenticate,
108 reqVideoFileUpdate, 97 reqVideoFileUpdate,
@@ -432,15 +421,3 @@ async function removeVideo (req: express.Request, res: express.Response) {
432 421
433 return res.type('json').status(204).end() 422 return res.type('json').status(204).end()
434} 423}
435
436async function searchVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
437 const resultList = await VideoModel.searchAndPopulateAccountAndServer(
438 req.query.search as string,
439 req.query.start as number,
440 req.query.count as number,
441 req.query.sort as VideoSortField,
442 isNSFWHidden(res)
443 )
444
445 return res.json(getFormattedObjects(resultList.data, resultList.total))
446}
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index 352d45fbf..bbb518c1b 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -5,6 +5,7 @@ import { ACCEPT_HEADERS, STATIC_MAX_AGE } from '../initializers'
5import { asyncMiddleware } from '../middlewares' 5import { asyncMiddleware } from '../middlewares'
6import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n' 6import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '../../shared/models/i18n/i18n'
7import { ClientHtml } from '../lib/client-html' 7import { ClientHtml } from '../lib/client-html'
8import { logger } from '../helpers/logger'
8 9
9const clientsRouter = express.Router() 10const clientsRouter = express.Router()
10 11
@@ -66,9 +67,14 @@ clientsRouter.use('/client/*', (req: express.Request, res: express.Response, nex
66 67
67// Always serve index client page (the client is a single page application, let it handle routing) 68// Always serve index client page (the client is a single page application, let it handle routing)
68// Try to provide the right language index.html 69// Try to provide the right language index.html
69clientsRouter.use('/(:language)?', function (req, res) { 70clientsRouter.use('/(:language)?', async function (req, res) {
70 if (req.accepts(ACCEPT_HEADERS) === 'html') { 71 if (req.accepts(ACCEPT_HEADERS) === 'html') {
71 return generateHTMLPage(req, res, req.params.language) 72 try {
73 await generateHTMLPage(req, res, req.params.language)
74 return
75 } catch (err) {
76 logger.error('Cannot generate HTML page.', err)
77 }
72 } 78 }
73 79
74 return res.status(404).end() 80 return res.status(404).end()
diff --git a/server/helpers/database-utils.ts b/server/helpers/database-utils.ts
index 11304cafb..53f881fb3 100644
--- a/server/helpers/database-utils.ts
+++ b/server/helpers/database-utils.ts
@@ -1,6 +1,6 @@
1import * as retry from 'async/retry' 1import * as retry from 'async/retry'
2import * as Bluebird from 'bluebird' 2import * as Bluebird from 'bluebird'
3import { Model } from 'sequelize-typescript' 3import { Model, Sequelize } from 'sequelize-typescript'
4import { logger } from './logger' 4import { logger } from './logger'
5 5
6function retryTransactionWrapper <T, A, B, C> ( 6function retryTransactionWrapper <T, A, B, C> (
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index ba48399de..b966c0acb 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -35,7 +35,9 @@ const SORTABLE_COLUMNS = {
35 VIDEO_COMMENT_THREADS: [ 'createdAt' ], 35 VIDEO_COMMENT_THREADS: [ 'createdAt' ],
36 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], 36 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
37 FOLLOWERS: [ 'createdAt' ], 37 FOLLOWERS: [ 'createdAt' ],
38 FOLLOWING: [ 'createdAt' ] 38 FOLLOWING: [ 'createdAt' ],
39
40 VIDEOS_SEARCH: [ 'bestmatch', 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes' ]
39} 41}
40 42
41const OAUTH_LIFETIME = { 43const OAUTH_LIFETIME = {
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 434d7ef19..045f41a96 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -80,6 +80,14 @@ async function initDatabaseModels (silent: boolean) {
80 ScheduleVideoUpdateModel 80 ScheduleVideoUpdateModel
81 ]) 81 ])
82 82
83 // Check extensions exist in the database
84 await checkPostgresExtensions()
85
86 // Create custom PostgreSQL functions
87 await createFunctions()
88
89 await sequelizeTypescript.query('CREATE EXTENSION IF NOT EXISTS pg_trgm', { raw: true })
90
83 if (!silent) logger.info('Database %s is ready.', dbname) 91 if (!silent) logger.info('Database %s is ready.', dbname)
84 92
85 return 93 return
@@ -91,3 +99,38 @@ export {
91 initDatabaseModels, 99 initDatabaseModels,
92 sequelizeTypescript 100 sequelizeTypescript
93} 101}
102
103// ---------------------------------------------------------------------------
104
105async function checkPostgresExtensions () {
106 const extensions = [
107 'pg_trgm',
108 'unaccent'
109 ]
110
111 for (const extension of extensions) {
112 const query = `SELECT true AS enabled FROM pg_available_extensions WHERE name = '${extension}' AND installed_version IS NOT NULL;`
113 const [ res ] = await sequelizeTypescript.query(query, { raw: true })
114
115 if (!res || res.length === 0 || res[ 0 ][ 'enabled' ] !== true) {
116 // Try to create the extension ourself
117 try {
118 await sequelizeTypescript.query(`CREATE EXTENSION ${extension};`, { raw: true })
119
120 } catch {
121 const errorMessage = `You need to enable ${extension} extension in PostgreSQL. ` +
122 `You can do so by running 'CREATE EXTENSION ${extension};' as a PostgreSQL super user in ${CONFIG.DATABASE.DBNAME} database.`
123 throw new Error(errorMessage)
124 }
125 }
126 }
127}
128
129async function createFunctions () {
130 const query = `CREATE OR REPLACE FUNCTION immutable_unaccent(varchar)
131 RETURNS text AS $$
132 SELECT unaccent($1)
133 $$ LANGUAGE sql IMMUTABLE;`
134
135 return sequelizeTypescript.query(query, { raw: true })
136}
diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts
index cdb809e75..6307ee154 100644
--- a/server/middlewares/sort.ts
+++ b/server/middlewares/sort.ts
@@ -8,6 +8,12 @@ function setDefaultSort (req: express.Request, res: express.Response, next: expr
8 return next() 8 return next()
9} 9}
10 10
11function setDefaultSearchSort (req: express.Request, res: express.Response, next: express.NextFunction) {
12 if (!req.query.sort) req.query.sort = '-bestmatch'
13
14 return next()
15}
16
11function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) { 17function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
12 let newSort: SortType = { sortModel: undefined, sortValue: undefined } 18 let newSort: SortType = { sortModel: undefined, sortValue: undefined }
13 19
@@ -33,5 +39,6 @@ function setBlacklistSort (req: express.Request, res: express.Response, next: ex
33 39
34export { 40export {
35 setDefaultSort, 41 setDefaultSort,
42 setDefaultSearchSort,
36 setBlacklistSort 43 setBlacklistSort
37} 44}
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index b69e1f14b..e3f0f5963 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -10,3 +10,4 @@ export * from './videos'
10export * from './video-blacklist' 10export * from './video-blacklist'
11export * from './video-channels' 11export * from './video-channels'
12export * from './webfinger' 12export * from './webfinger'
13export * from './search'
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
new file mode 100644
index 000000000..774845e8a
--- /dev/null
+++ b/server/middlewares/validators/search.ts
@@ -0,0 +1,22 @@
1import * as express from 'express'
2import { areValidationErrors } from './utils'
3import { logger } from '../../helpers/logger'
4import { query } from 'express-validator/check'
5
6const searchValidator = [
7 query('search').not().isEmpty().withMessage('Should have a valid search'),
8
9 (req: express.Request, res: express.Response, next: express.NextFunction) => {
10 logger.debug('Checking search parameters', { parameters: req.params })
11
12 if (areValidationErrors(req, res)) return
13
14 return next()
15 }
16]
17
18// ---------------------------------------------------------------------------
19
20export {
21 searchValidator
22}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 925f47e57..00bde548c 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -7,6 +7,7 @@ const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNT
7const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS) 7const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
8const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES) 8const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) 9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
10const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) 11const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
11const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS) 12const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACKLISTS)
12const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS) 13const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
@@ -18,6 +19,7 @@ const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
18const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS) 19const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
19const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS) 20const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
20const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) 21const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
22const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
21const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) 23const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
22const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) 24const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
23const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) 25const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
@@ -30,6 +32,7 @@ export {
30 usersSortValidator, 32 usersSortValidator,
31 videoAbusesSortValidator, 33 videoAbusesSortValidator,
32 videoChannelsSortValidator, 34 videoChannelsSortValidator,
35 videosSearchSortValidator,
33 videosSortValidator, 36 videosSortValidator,
34 blacklistSortValidator, 37 blacklistSortValidator,
35 accountsSortValidator, 38 accountsSortValidator,
diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts
index abb23b510..d9af2aa0a 100644
--- a/server/middlewares/validators/videos.ts
+++ b/server/middlewares/validators/videos.ts
@@ -1,6 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import 'express-validator' 2import 'express-validator'
3import { body, param, query, ValidationChain } from 'express-validator/check' 3import { body, param, ValidationChain } from 'express-validator/check'
4import { UserRight, VideoPrivacy } from '../../../shared' 4import { UserRight, VideoPrivacy } from '../../../shared'
5import { 5import {
6 isBooleanValid, 6 isBooleanValid,
@@ -172,18 +172,6 @@ const videosRemoveValidator = [
172 } 172 }
173] 173]
174 174
175const videosSearchValidator = [
176 query('search').not().isEmpty().withMessage('Should have a valid search'),
177
178 (req: express.Request, res: express.Response, next: express.NextFunction) => {
179 logger.debug('Checking videosSearch parameters', { parameters: req.params })
180
181 if (areValidationErrors(req, res)) return
182
183 return next()
184 }
185]
186
187const videoAbuseReportValidator = [ 175const videoAbuseReportValidator = [
188 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 176 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
189 body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'), 177 body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
@@ -240,7 +228,6 @@ export {
240 videosUpdateValidator, 228 videosUpdateValidator,
241 videosGetValidator, 229 videosGetValidator,
242 videosRemoveValidator, 230 videosRemoveValidator,
243 videosSearchValidator,
244 videosShareValidator, 231 videosShareValidator,
245 232
246 videoAbuseReportValidator, 233 videoAbuseReportValidator,
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 1d0e54ee3..38a689fea 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -88,6 +88,12 @@ enum ScopeNames {
88 }, 88 },
89 { 89 {
90 fields: [ 'inboxUrl', 'sharedInboxUrl' ] 90 fields: [ 'inboxUrl', 'sharedInboxUrl' ]
91 },
92 {
93 fields: [ 'serverId' ]
94 },
95 {
96 fields: [ 'avatarId' ]
91 } 97 }
92 ] 98 ]
93}) 99})
diff --git a/server/models/utils.ts b/server/models/utils.ts
index 59ce83c16..49d32c24f 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -1,6 +1,8 @@
1// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] 1// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
2import { Sequelize } from 'sequelize-typescript'
3
2function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) { 4function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
3 let field: string 5 let field: any
4 let direction: 'ASC' | 'DESC' 6 let direction: 'ASC' | 'DESC'
5 7
6 if (value.substring(0, 1) === '-') { 8 if (value.substring(0, 1) === '-') {
@@ -11,6 +13,9 @@ function getSort (value: string, lastSort: string[] = [ 'id', 'ASC' ]) {
11 field = value 13 field = value
12 } 14 }
13 15
16 // Alias
17 if (field.toLowerCase() === 'bestmatch') field = Sequelize.col('similarity')
18
14 return [ [ field, direction ], lastSort ] 19 return [ [ field, direction ], lastSort ]
15} 20}
16 21
@@ -27,10 +32,53 @@ function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldN
27 } 32 }
28} 33}
29 34
35function buildTrigramSearchIndex (indexName: string, attribute: string) {
36 return {
37 name: indexName,
38 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + '))') as any ],
39 using: 'gin',
40 operator: 'gin_trgm_ops'
41 }
42}
43
44function createSimilarityAttribute (col: string, value: string) {
45 return Sequelize.fn(
46 'similarity',
47
48 searchTrigramNormalizeCol(col),
49
50 searchTrigramNormalizeValue(value)
51 )
52}
53
54function createSearchTrigramQuery (col: string, value: string) {
55 return {
56 [ Sequelize.Op.or ]: [
57 // FIXME: use word_similarity instead of just similarity?
58 Sequelize.where(searchTrigramNormalizeCol(col), ' % ', searchTrigramNormalizeValue(value)),
59
60 Sequelize.where(searchTrigramNormalizeCol(col), ' LIKE ', searchTrigramNormalizeValue(`%${value}%`))
61 ]
62 }
63}
64
30// --------------------------------------------------------------------------- 65// ---------------------------------------------------------------------------
31 66
32export { 67export {
33 getSort, 68 getSort,
34 getSortOnModel, 69 getSortOnModel,
35 throwIfNotValid 70 createSimilarityAttribute,
71 throwIfNotValid,
72 buildTrigramSearchIndex,
73 createSearchTrigramQuery
74}
75
76// ---------------------------------------------------------------------------
77
78function searchTrigramNormalizeValue (value: string) {
79 return Sequelize.fn('lower', Sequelize.fn('unaccent', value))
80}
81
82function searchTrigramNormalizeCol (col: string) {
83 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
36} 84}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 74a3a5d05..15b4dda5b 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -83,7 +83,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
83import { ActorModel } from '../activitypub/actor' 83import { ActorModel } from '../activitypub/actor'
84import { AvatarModel } from '../avatar/avatar' 84import { AvatarModel } from '../avatar/avatar'
85import { ServerModel } from '../server/server' 85import { ServerModel } from '../server/server'
86import { getSort, throwIfNotValid } from '../utils' 86import { buildTrigramSearchIndex, createSearchTrigramQuery, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
87import { TagModel } from './tag' 87import { TagModel } from './tag'
88import { VideoAbuseModel } from './video-abuse' 88import { VideoAbuseModel } from './video-abuse'
89import { VideoChannelModel } from './video-channel' 89import { VideoChannelModel } from './video-channel'
@@ -94,6 +94,37 @@ import { VideoTagModel } from './video-tag'
94import { ScheduleVideoUpdateModel } from './schedule-video-update' 94import { ScheduleVideoUpdateModel } from './schedule-video-update'
95import { VideoCaptionModel } from './video-caption' 95import { VideoCaptionModel } from './video-caption'
96 96
97// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
98const indexes: Sequelize.DefineIndexesOptions[] = [
99 buildTrigramSearchIndex('video_name_trigram', 'name'),
100
101 {
102 fields: [ 'createdAt' ]
103 },
104 {
105 fields: [ 'duration' ]
106 },
107 {
108 fields: [ 'views' ]
109 },
110 {
111 fields: [ 'likes' ]
112 },
113 {
114 fields: [ 'uuid' ]
115 },
116 {
117 fields: [ 'channelId' ]
118 },
119 {
120 fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
121 },
122 {
123 fields: [ 'url'],
124 unique: true
125 }
126]
127
97export enum ScopeNames { 128export enum ScopeNames {
98 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 129 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
99 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', 130 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
@@ -309,36 +340,7 @@ export enum ScopeNames {
309}) 340})
310@Table({ 341@Table({
311 tableName: 'video', 342 tableName: 'video',
312 indexes: [ 343 indexes
313 {
314 fields: [ 'name' ]
315 },
316 {
317 fields: [ 'createdAt' ]
318 },
319 {
320 fields: [ 'duration' ]
321 },
322 {
323 fields: [ 'views' ]
324 },
325 {
326 fields: [ 'likes' ]
327 },
328 {
329 fields: [ 'uuid' ]
330 },
331 {
332 fields: [ 'channelId' ]
333 },
334 {
335 fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ]
336 },
337 {
338 fields: [ 'url'],
339 unique: true
340 }
341 ]
342}) 344})
343export class VideoModel extends Model<VideoModel> { 345export class VideoModel extends Model<VideoModel> {
344 346
@@ -794,33 +796,13 @@ export class VideoModel extends Model<VideoModel> {
794 796
795 static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) { 797 static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
796 const query: IFindOptions<VideoModel> = { 798 const query: IFindOptions<VideoModel> = {
799 attributes: {
800 include: [ createSimilarityAttribute('VideoModel.name', value) ]
801 },
797 offset: start, 802 offset: start,
798 limit: count, 803 limit: count,
799 order: getSort(sort), 804 order: getSort(sort),
800 where: { 805 where: createSearchTrigramQuery('VideoModel.name', value)
801 [Sequelize.Op.or]: [
802 {
803 name: {
804 [ Sequelize.Op.iLike ]: '%' + value + '%'
805 }
806 },
807 {
808 preferredUsernameChannel: Sequelize.where(Sequelize.col('VideoChannel->Actor.preferredUsername'), {
809 [ Sequelize.Op.iLike ]: '%' + value + '%'
810 })
811 },
812 {
813 preferredUsernameAccount: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor.preferredUsername'), {
814 [ Sequelize.Op.iLike ]: '%' + value + '%'
815 })
816 },
817 {
818 host: Sequelize.where(Sequelize.col('VideoChannel->Account->Actor->Server.host'), {
819 [ Sequelize.Op.iLike ]: '%' + value + '%'
820 })
821 }
822 ]
823 }
824 } 806 }
825 807
826 const serverActor = await getServerActor() 808 const serverActor = await getServerActor()
diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts
index 74bf7354e..a42d0f043 100644
--- a/server/tests/utils/videos/videos.ts
+++ b/server/tests/utils/videos/videos.ts
@@ -248,9 +248,9 @@ function removeVideo (url: string, token: string, id: number | string, expectedS
248} 248}
249 249
250function searchVideo (url: string, search: string) { 250function searchVideo (url: string, search: string) {
251 const path = '/api/v1/videos' 251 const path = '/api/v1/search/videos'
252 const req = request(url) 252 const req = request(url)
253 .get(path + '/search') 253 .get(path)
254 .query({ search }) 254 .query({ search })
255 .set('Accept', 'application/json') 255 .set('Accept', 'application/json')
256 256
@@ -271,10 +271,10 @@ function searchVideoWithToken (url: string, search: string, token: string) {
271} 271}
272 272
273function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) { 273function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) {
274 const path = '/api/v1/videos' 274 const path = '/api/v1/search/videos'
275 275
276 const req = request(url) 276 const req = request(url)
277 .get(path + '/search') 277 .get(path)
278 .query({ start }) 278 .query({ start })
279 .query({ search }) 279 .query({ search })
280 .query({ count }) 280 .query({ count })
@@ -287,10 +287,10 @@ function searchVideoWithPagination (url: string, search: string, start: number,
287} 287}
288 288
289function searchVideoWithSort (url: string, search: string, sort: string) { 289function searchVideoWithSort (url: string, search: string, sort: string) {
290 const path = '/api/v1/videos' 290 const path = '/api/v1/search/videos'
291 291
292 return request(url) 292 return request(url)
293 .get(path + '/search') 293 .get(path)
294 .query({ search }) 294 .query({ search })
295 .query({ sort }) 295 .query({ sort })
296 .set('Accept', 'application/json') 296 .set('Accept', 'application/json')
diff --git a/support/doc/docker.md b/support/doc/docker.md
index 087961802..cab336344 100644
--- a/support/doc/docker.md
+++ b/support/doc/docker.md
@@ -53,6 +53,10 @@ $ docker-compose up
53**Important**: note that you'll get the initial `root` user password from the 53**Important**: note that you'll get the initial `root` user password from the
54program output, so check out your logs to find them. 54program output, so check out your logs to find them.
55 55
56### What now?
57
58See the production guide ["What now" section](/support/doc/production.md#what-now).
59
56### Upgrade 60### Upgrade
57 61
58Pull the latest images and rerun PeerTube: 62Pull the latest images and rerun PeerTube:
diff --git a/support/doc/production.md b/support/doc/production.md
index 8310e7fda..8d2a4da11 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -41,6 +41,13 @@ $ sudo -u postgres createuser -P peertube
41$ sudo -u postgres createdb -O peertube peertube_prod 41$ sudo -u postgres createdb -O peertube peertube_prod
42``` 42```
43 43
44Then enable extensions PeerTube needs:
45
46```
47$ sudo -u postgres psql -c "CREATE EXTENSION pg_trgm;" peertube_prod
48$ sudo -u postgres psql -c "CREATE EXTENSION unaccent;" peertube_prod
49```
50
44### Prepare PeerTube directory 51### Prepare PeerTube directory
45 52
46Fetch the latest tagged version of Peertube 53Fetch the latest tagged version of Peertube
@@ -194,7 +201,7 @@ Now your instance is up you can:
194 201
195## Upgrade 202## Upgrade
196 203
197### PeerTube code 204### PeerTube instance
198 205
199**Check the changelog (in particular BREAKING CHANGES!):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md 206**Check the changelog (in particular BREAKING CHANGES!):** https://github.com/Chocobozzz/PeerTube/blob/develop/CHANGELOG.md
200 207