aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html16
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts4
-rw-r--r--client/src/app/account/account-settings/account-details/account-details.component.html19
-rw-r--r--client/src/app/account/account-settings/account-details/account-details.component.scss6
-rw-r--r--client/src/app/account/account-settings/account-details/account-details.component.ts6
-rw-r--r--client/src/app/core/auth/auth-user.model.ts9
-rw-r--r--client/src/app/core/server/server.service.ts2
-rw-r--r--client/src/app/shared/users/user.model.ts9
-rw-r--r--client/src/app/shared/video/video-miniature.component.html4
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts7
-rw-r--r--client/src/app/shared/video/video.model.ts13
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts4
-rw-r--r--config/default.yaml3
-rw-r--r--config/production.yaml.example3
-rw-r--r--config/test.yaml3
-rw-r--r--server/controllers/api/config.ts3
-rw-r--r--server/controllers/api/users.ts15
-rw-r--r--server/controllers/api/videos/index.ts38
-rw-r--r--server/controllers/feeds.ts9
-rw-r--r--server/helpers/custom-validators/users.ts10
-rw-r--r--server/initializers/checker.ts11
-rw-r--r--server/initializers/constants.ts11
-rw-r--r--server/initializers/installer.ts1
-rw-r--r--server/initializers/migrations/0205-user-nsfw-policy.ts46
-rw-r--r--server/middlewares/oauth.ts10
-rw-r--r--server/middlewares/validators/config.ts3
-rw-r--r--server/middlewares/validators/users.ts4
-rw-r--r--server/models/account/user.ts14
-rw-r--r--server/models/video/video.ts24
-rw-r--r--server/tests/api/check-params/config.ts19
-rw-r--r--server/tests/api/check-params/users.ts6
-rw-r--r--server/tests/api/server/config.ts5
-rw-r--r--server/tests/api/users/users.ts26
-rw-r--r--server/tests/api/videos/video-nsfw.ts197
-rw-r--r--server/tests/utils/users/users.ts5
-rw-r--r--server/tests/utils/videos/videos.ts26
-rw-r--r--shared/models/server/custom-config.model.ts3
-rw-r--r--shared/models/server/server-config.model.ts3
-rw-r--r--shared/models/users/user-update-me.model.ts4
-rw-r--r--shared/models/users/user.model.ts3
-rw-r--r--shared/models/videos/nsfw-policy.type.ts1
41 files changed, 519 insertions, 86 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 714a3af15..df40bba9f 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -62,6 +62,22 @@
62 </div> 62 </div>
63 </div> 63 </div>
64 64
65 <div class="form-group">
66 <label for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label>
67 <my-help helpType="custom" customHtml="With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video."></my-help>
68
69 <div class="peertube-select-container">
70 <select id="instanceDefaultNSFWPolicy" formControlName="instanceDefaultNSFWPolicy">
71 <option value="do_not_list">Do not list</option>
72 <option value="blur">Blur thumbnails</option>
73 <option value="display">Display</option>
74 </select>
75 </div>
76 <div *ngIf="formErrors.instanceDefaultNSFWPolicy" class="form-error">
77 {{ formErrors.instanceDefaultNSFWPolicy }}
78 </div>
79 </div>
80
65 <div class="inner-form-title">Cache</div> 81 <div class="inner-form-title">Cache</div>
66 82
67 <div class="form-group"> 83 <div class="form-group">
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index d73ee71e4..2ab371cbb 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -48,6 +48,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
48 instanceDescription: '', 48 instanceDescription: '',
49 instanceTerms: '', 49 instanceTerms: '',
50 instanceDefaultClientRoute: '', 50 instanceDefaultClientRoute: '',
51 instanceDefaultNSFWPolicy: '',
51 cachePreviewsSize: '', 52 cachePreviewsSize: '',
52 signupLimit: '', 53 signupLimit: '',
53 adminEmail: '', 54 adminEmail: '',
@@ -90,6 +91,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
90 instanceDescription: [ '' ], 91 instanceDescription: [ '' ],
91 instanceTerms: [ '' ], 92 instanceTerms: [ '' ],
92 instanceDefaultClientRoute: [ '' ], 93 instanceDefaultClientRoute: [ '' ],
94 instanceDefaultNSFWPolicy: [ '' ],
93 cachePreviewsSize: [ '', CACHE_PREVIEWS_SIZE.VALIDATORS ], 95 cachePreviewsSize: [ '', CACHE_PREVIEWS_SIZE.VALIDATORS ],
94 signupEnabled: [ ], 96 signupEnabled: [ ],
95 signupLimit: [ '', SIGNUP_LIMIT.VALIDATORS ], 97 signupLimit: [ '', SIGNUP_LIMIT.VALIDATORS ],
@@ -167,6 +169,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
167 description: this.form.value['instanceDescription'], 169 description: this.form.value['instanceDescription'],
168 terms: this.form.value['instanceTerms'], 170 terms: this.form.value['instanceTerms'],
169 defaultClientRoute: this.form.value['instanceDefaultClientRoute'], 171 defaultClientRoute: this.form.value['instanceDefaultClientRoute'],
172 defaultNSFWPolicy: this.form.value['instanceDefaultNSFWPolicy'],
170 customizations: { 173 customizations: {
171 javascript: this.form.value['customizationJavascript'], 174 javascript: this.form.value['customizationJavascript'],
172 css: this.form.value['customizationCSS'] 175 css: this.form.value['customizationCSS']
@@ -224,6 +227,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
224 instanceDescription: this.customConfig.instance.description, 227 instanceDescription: this.customConfig.instance.description,
225 instanceTerms: this.customConfig.instance.terms, 228 instanceTerms: this.customConfig.instance.terms,
226 instanceDefaultClientRoute: this.customConfig.instance.defaultClientRoute, 229 instanceDefaultClientRoute: this.customConfig.instance.defaultClientRoute,
230 instanceDefaultNSFWPolicy: this.customConfig.instance.defaultNSFWPolicy,
227 cachePreviewsSize: this.customConfig.cache.previews.size, 231 cachePreviewsSize: this.customConfig.cache.previews.size,
228 signupEnabled: this.customConfig.signup.enabled, 232 signupEnabled: this.customConfig.signup.enabled,
229 signupLimit: this.customConfig.signup.limit, 233 signupLimit: this.customConfig.signup.limit,
diff --git a/client/src/app/account/account-settings/account-details/account-details.component.html b/client/src/app/account/account-settings/account-details/account-details.component.html
index 8f1475a4d..9dcc66a75 100644
--- a/client/src/app/account/account-settings/account-details/account-details.component.html
+++ b/client/src/app/account/account-settings/account-details/account-details.component.html
@@ -1,11 +1,18 @@
1<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form"> 1<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form">
2 <div class="form-group"> 2 <div class="form-group">
3 <input 3 <label for="nsfwPolicy">Default policy on videos containing sensitive content</label>
4 type="checkbox" id="displayNSFW" 4 <my-help helpType="custom" customHtml="With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video."></my-help>
5 formControlName="displayNSFW" 5
6 > 6 <div class="peertube-select-container">
7 <label for="displayNSFW"></label> 7 <select id="nsfwPolicy" formControlName="nsfwPolicy">
8 <label for="displayNSFW">Display videos that contain mature or explicit content</label> 8 <option value="do_not_list">Do not list</option>
9 <option value="blur">Blur thumbnails</option>
10 <option value="display">Display</option>
11 </select>
12 </div>
13 <div *ngIf="formErrors.nsfwPolicy" class="form-error">
14 {{ formErrors.nsfwPolicy }}
15 </div>
9 </div> 16 </div>
10 17
11 <div class="form-group"> 18 <div class="form-group">
diff --git a/client/src/app/account/account-settings/account-details/account-details.component.scss b/client/src/app/account/account-settings/account-details/account-details.component.scss
index 4e8dfde1d..ed59e4689 100644
--- a/client/src/app/account/account-settings/account-details/account-details.component.scss
+++ b/client/src/app/account/account-settings/account-details/account-details.component.scss
@@ -12,3 +12,9 @@ input[type=submit] {
12 display: block; 12 display: block;
13 margin-top: 15px; 13 margin-top: 15px;
14} 14}
15
16.peertube-select-container {
17 @include peertube-select-container(340px);
18
19 margin-bottom: 30px;
20} \ No newline at end of file
diff --git a/client/src/app/account/account-settings/account-details/account-details.component.ts b/client/src/app/account/account-settings/account-details/account-details.component.ts
index 917f31651..de213717e 100644
--- a/client/src/app/account/account-settings/account-details/account-details.component.ts
+++ b/client/src/app/account/account-settings/account-details/account-details.component.ts
@@ -29,7 +29,7 @@ export class AccountDetailsComponent extends FormReactive implements OnInit {
29 29
30 buildForm () { 30 buildForm () {
31 this.form = this.formBuilder.group({ 31 this.form = this.formBuilder.group({
32 displayNSFW: [ this.user.displayNSFW ], 32 nsfwPolicy: [ this.user.nsfwPolicy ],
33 autoPlayVideo: [ this.user.autoPlayVideo ] 33 autoPlayVideo: [ this.user.autoPlayVideo ]
34 }) 34 })
35 35
@@ -41,10 +41,10 @@ export class AccountDetailsComponent extends FormReactive implements OnInit {
41 } 41 }
42 42
43 updateDetails () { 43 updateDetails () {
44 const displayNSFW = this.form.value['displayNSFW'] 44 const nsfwPolicy = this.form.value['nsfwPolicy']
45 const autoPlayVideo = this.form.value['autoPlayVideo'] 45 const autoPlayVideo = this.form.value['autoPlayVideo']
46 const details: UserUpdateMe = { 46 const details: UserUpdateMe = {
47 displayNSFW, 47 nsfwPolicy,
48 autoPlayVideo 48 autoPlayVideo
49 } 49 }
50 50
diff --git a/client/src/app/core/auth/auth-user.model.ts b/client/src/app/core/auth/auth-user.model.ts
index 366eea110..60fe57899 100644
--- a/client/src/app/core/auth/auth-user.model.ts
+++ b/client/src/app/core/auth/auth-user.model.ts
@@ -3,6 +3,7 @@ import { UserRight } from '../../../../../shared/models/users/user-right.enum'
3// Do not use the barrel (dependency loop) 3// Do not use the barrel (dependency loop)
4import { hasUserRight, UserRole } from '../../../../../shared/models/users/user-role' 4import { hasUserRight, UserRole } from '../../../../../shared/models/users/user-role'
5import { User, UserConstructorHash } from '../../shared/users/user.model' 5import { User, UserConstructorHash } from '../../shared/users/user.model'
6import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
6 7
7export type TokenOptions = { 8export type TokenOptions = {
8 accessToken: string 9 accessToken: string
@@ -70,7 +71,7 @@ export class AuthUser extends User {
70 ROLE: 'role', 71 ROLE: 'role',
71 EMAIL: 'email', 72 EMAIL: 'email',
72 USERNAME: 'username', 73 USERNAME: 'username',
73 DISPLAY_NSFW: 'display_nsfw', 74 DEFAULT_NSFW_POLICY: 'nsfw_policy',
74 AUTO_PLAY_VIDEO: 'auto_play_video' 75 AUTO_PLAY_VIDEO: 'auto_play_video'
75 } 76 }
76 77
@@ -85,7 +86,7 @@ export class AuthUser extends User {
85 username: peertubeLocalStorage.getItem(this.KEYS.USERNAME), 86 username: peertubeLocalStorage.getItem(this.KEYS.USERNAME),
86 email: peertubeLocalStorage.getItem(this.KEYS.EMAIL), 87 email: peertubeLocalStorage.getItem(this.KEYS.EMAIL),
87 role: parseInt(peertubeLocalStorage.getItem(this.KEYS.ROLE), 10) as UserRole, 88 role: parseInt(peertubeLocalStorage.getItem(this.KEYS.ROLE), 10) as UserRole,
88 displayNSFW: peertubeLocalStorage.getItem(this.KEYS.DISPLAY_NSFW) === 'true', 89 nsfwPolicy: peertubeLocalStorage.getItem(this.KEYS.DEFAULT_NSFW_POLICY) as NSFWPolicyType,
89 autoPlayVideo: peertubeLocalStorage.getItem(this.KEYS.AUTO_PLAY_VIDEO) === 'true' 90 autoPlayVideo: peertubeLocalStorage.getItem(this.KEYS.AUTO_PLAY_VIDEO) === 'true'
90 }, 91 },
91 Tokens.load() 92 Tokens.load()
@@ -99,7 +100,7 @@ export class AuthUser extends User {
99 peertubeLocalStorage.removeItem(this.KEYS.USERNAME) 100 peertubeLocalStorage.removeItem(this.KEYS.USERNAME)
100 peertubeLocalStorage.removeItem(this.KEYS.ID) 101 peertubeLocalStorage.removeItem(this.KEYS.ID)
101 peertubeLocalStorage.removeItem(this.KEYS.ROLE) 102 peertubeLocalStorage.removeItem(this.KEYS.ROLE)
102 peertubeLocalStorage.removeItem(this.KEYS.DISPLAY_NSFW) 103 peertubeLocalStorage.removeItem(this.KEYS.DEFAULT_NSFW_POLICY)
103 peertubeLocalStorage.removeItem(this.KEYS.AUTO_PLAY_VIDEO) 104 peertubeLocalStorage.removeItem(this.KEYS.AUTO_PLAY_VIDEO)
104 peertubeLocalStorage.removeItem(this.KEYS.EMAIL) 105 peertubeLocalStorage.removeItem(this.KEYS.EMAIL)
105 Tokens.flush() 106 Tokens.flush()
@@ -136,7 +137,7 @@ export class AuthUser extends User {
136 peertubeLocalStorage.setItem(AuthUser.KEYS.USERNAME, this.username) 137 peertubeLocalStorage.setItem(AuthUser.KEYS.USERNAME, this.username)
137 peertubeLocalStorage.setItem(AuthUser.KEYS.EMAIL, this.email) 138 peertubeLocalStorage.setItem(AuthUser.KEYS.EMAIL, this.email)
138 peertubeLocalStorage.setItem(AuthUser.KEYS.ROLE, this.role.toString()) 139 peertubeLocalStorage.setItem(AuthUser.KEYS.ROLE, this.role.toString())
139 peertubeLocalStorage.setItem(AuthUser.KEYS.DISPLAY_NSFW, JSON.stringify(this.displayNSFW)) 140 peertubeLocalStorage.setItem(AuthUser.KEYS.DEFAULT_NSFW_POLICY, this.nsfwPolicy.toString())
140 peertubeLocalStorage.setItem(AuthUser.KEYS.AUTO_PLAY_VIDEO, JSON.stringify(this.autoPlayVideo)) 141 peertubeLocalStorage.setItem(AuthUser.KEYS.AUTO_PLAY_VIDEO, JSON.stringify(this.autoPlayVideo))
141 this.tokens.save() 142 this.tokens.save()
142 } 143 }
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 987d64d2a..a8beb242d 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -5,7 +5,6 @@ import 'rxjs/add/operator/do'
5import { ReplaySubject } from 'rxjs/ReplaySubject' 5import { ReplaySubject } from 'rxjs/ReplaySubject'
6import { ServerConfig } from '../../../../../shared' 6import { ServerConfig } from '../../../../../shared'
7import { About } from '../../../../../shared/models/server/about.model' 7import { About } from '../../../../../shared/models/server/about.model'
8import { ServerStats } from '../../../../../shared/models/server/server-stats.model'
9import { environment } from '../../../environments/environment' 8import { environment } from '../../../environments/environment'
10 9
11@Injectable() 10@Injectable()
@@ -26,6 +25,7 @@ export class ServerService {
26 shortDescription: 'PeerTube, a federated (ActivityPub) video streaming platform ' + 25 shortDescription: 'PeerTube, a federated (ActivityPub) video streaming platform ' +
27 'using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.', 26 'using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.',
28 defaultClientRoute: '', 27 defaultClientRoute: '',
28 defaultNSFWPolicy: 'do_not_list' as 'do_not_list',
29 customizations: { 29 customizations: {
30 javascript: '', 30 javascript: '',
31 css: '' 31 css: ''
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts
index 4a94b032d..2bdc48a1d 100644
--- a/client/src/app/shared/users/user.model.ts
+++ b/client/src/app/shared/users/user.model.ts
@@ -1,5 +1,6 @@
1import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared' 1import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared'
2import { Account } from '../account/account.model' 2import { Account } from '../account/account.model'
3import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
3 4
4export type UserConstructorHash = { 5export type UserConstructorHash = {
5 id: number, 6 id: number,
@@ -7,7 +8,7 @@ export type UserConstructorHash = {
7 email: string, 8 email: string,
8 role: UserRole, 9 role: UserRole,
9 videoQuota?: number, 10 videoQuota?: number,
10 displayNSFW?: boolean, 11 nsfwPolicy?: NSFWPolicyType,
11 autoPlayVideo?: boolean, 12 autoPlayVideo?: boolean,
12 createdAt?: Date, 13 createdAt?: Date,
13 account?: Account, 14 account?: Account,
@@ -18,7 +19,7 @@ export class User implements UserServerModel {
18 username: string 19 username: string
19 email: string 20 email: string
20 role: UserRole 21 role: UserRole
21 displayNSFW: boolean 22 nsfwPolicy: NSFWPolicyType
22 autoPlayVideo: boolean 23 autoPlayVideo: boolean
23 videoQuota: number 24 videoQuota: number
24 account: Account 25 account: Account
@@ -40,8 +41,8 @@ export class User implements UserServerModel {
40 this.videoQuota = hash.videoQuota 41 this.videoQuota = hash.videoQuota
41 } 42 }
42 43
43 if (hash.displayNSFW !== undefined) { 44 if (hash.nsfwPolicy !== undefined) {
44 this.displayNSFW = hash.displayNSFW 45 this.nsfwPolicy = hash.nsfwPolicy
45 } 46 }
46 47
47 if (hash.autoPlayVideo !== undefined) { 48 if (hash.autoPlayVideo !== undefined) {
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index f28e9b8d9..233432142 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -1,11 +1,11 @@
1<div class="video-miniature"> 1<div class="video-miniature">
2 <my-video-thumbnail [video]="video" [nsfw]="isVideoNSFWForThisUser()"></my-video-thumbnail> 2 <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur()"></my-video-thumbnail>
3 3
4 <div class="video-miniature-information"> 4 <div class="video-miniature-information">
5 <span class="video-miniature-name"> 5 <span class="video-miniature-name">
6 <a 6 <a
7 class="video-miniature-name" 7 class="video-miniature-name"
8 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoNSFWForThisUser() }" 8 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
9 > 9 >
10 {{ video.name }} 10 {{ video.name }}
11 </a> 11 </a>
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 4d79a74bb..d3f6dc1f6 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -1,6 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { User } from '../users' 2import { User } from '../users'
3import { Video } from './video.model' 3import { Video } from './video.model'
4import { ServerService } from '@app/core'
4 5
5@Component({ 6@Component({
6 selector: 'my-video-miniature', 7 selector: 'my-video-miniature',
@@ -11,7 +12,9 @@ export class VideoMiniatureComponent {
11 @Input() user: User 12 @Input() user: User
12 @Input() video: Video 13 @Input() video: Video
13 14
14 isVideoNSFWForThisUser () { 15 constructor (private serverService: ServerService) { }
15 return this.video.isVideoNSFWForUser(this.user) 16
17 isVideoBlur () {
18 return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
16 } 19 }
17} 20}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 0c02cbcb9..adc248a1e 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -4,6 +4,7 @@ import { Video as VideoServerModel } from '../../../../../shared'
4import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 4import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
5import { VideoConstant } from '../../../../../shared/models/videos/video.model' 5import { VideoConstant } from '../../../../../shared/models/videos/video.model'
6import { getAbsoluteAPIUrl } from '../misc/utils' 6import { getAbsoluteAPIUrl } from '../misc/utils'
7import { ServerConfig } from '../../../../../shared/models'
7 8
8export class Video implements VideoServerModel { 9export class Video implements VideoServerModel {
9 by: string 10 by: string
@@ -83,8 +84,14 @@ export class Video implements VideoServerModel {
83 this.by = Account.CREATE_BY_STRING(hash.account.name, hash.account.host) 84 this.by = Account.CREATE_BY_STRING(hash.account.name, hash.account.host)
84 } 85 }
85 86
86 isVideoNSFWForUser (user: User) { 87 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
87 // If the video is NSFW and the user is not logged in, or the user does not want to display NSFW videos... 88 // Video is not NSFW, skip
88 return (this.nsfw && (!user || user.displayNSFW === false)) 89 if (this.nsfw === false) return false
90
91 // Return user setting if logged in
92 if (user) return user.nsfwPolicy !== 'display'
93
94 // Return default instance config
95 return serverConfig.instance.defaultNSFWPolicy !== 'display'
89 } 96 }
90} 97}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index 182703cdf..6f6f02378 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -22,6 +22,7 @@ import { VideoDownloadComponent } from './modal/video-download.component'
22import { VideoReportComponent } from './modal/video-report.component' 22import { VideoReportComponent } from './modal/video-report.component'
23import { VideoShareComponent } from './modal/video-share.component' 23import { VideoShareComponent } from './modal/video-share.component'
24import { getVideojsOptions } from '../../../assets/player/peertube-player' 24import { getVideojsOptions } from '../../../assets/player/peertube-player'
25import { ServerService } from '@app/core'
25 26
26@Component({ 27@Component({
27 selector: 'my-video-watch', 28 selector: 'my-video-watch',
@@ -66,6 +67,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
66 private confirmService: ConfirmService, 67 private confirmService: ConfirmService,
67 private metaService: MetaService, 68 private metaService: MetaService,
68 private authService: AuthService, 69 private authService: AuthService,
70 private serverService: ServerService,
69 private notificationsService: NotificationsService, 71 private notificationsService: NotificationsService,
70 private markdownService: MarkdownService, 72 private markdownService: MarkdownService,
71 private zone: NgZone, 73 private zone: NgZone,
@@ -335,7 +337,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
335 337
336 this.updateOtherVideosDisplayed() 338 this.updateOtherVideosDisplayed()
337 339
338 if (this.video.isVideoNSFWForUser(this.user)) { 340 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
339 const res = await this.confirmService.confirm( 341 const res = await this.confirmService.confirm(
340 'This video contains mature or explicit content. Are you sure you want to watch it?', 342 'This video contains mature or explicit content. Are you sure you want to watch it?',
341 'Mature or explicit content' 343 'Mature or explicit content'
diff --git a/config/default.yaml b/config/default.yaml
index 9f4a76621..25dde72c9 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -84,6 +84,9 @@ instance:
84 description: 'Welcome to this PeerTube instance!' # Support markdown 84 description: 'Welcome to this PeerTube instance!' # Support markdown
85 terms: 'No terms for now.' # Support markdown 85 terms: 'No terms for now.' # Support markdown
86 default_client_route: '/videos/trending' 86 default_client_route: '/videos/trending'
87 # By default, "do_not_list" or "blur" or "display" NSFW videos
88 # Could be overridden per user with a setting
89 default_nsfw_policy: 'do_not_list'
87 customizations: 90 customizations:
88 javascript: '' # Directly your JavaScript code (without <script> tags). Will be eval at runtime 91 javascript: '' # Directly your JavaScript code (without <script> tags). Will be eval at runtime
89 css: '' # Directly your CSS code (without <style> tags). Will be injected at runtime 92 css: '' # Directly your CSS code (without <style> tags). Will be injected at runtime
diff --git a/config/production.yaml.example b/config/production.yaml.example
index fc06c51fb..1d7d35c9c 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -100,6 +100,9 @@ instance:
100 description: '' # Support markdown 100 description: '' # Support markdown
101 terms: '' # Support markdown 101 terms: '' # Support markdown
102 default_client_route: '/videos/trending' 102 default_client_route: '/videos/trending'
103 # By default, "do_not_list" or "blur" or "display" NSFW videos
104 # Could be overridden per user with a setting
105 default_nsfw_policy: 'do_not_list'
103 customizations: 106 customizations:
104 javascript: '' # Directly your JavaScript code (without <script> tags). Will be eval at runtime 107 javascript: '' # Directly your JavaScript code (without <script> tags). Will be eval at runtime
105 css: '' # Directly your CSS code (without <style> tags). Will be injected at runtime 108 css: '' # Directly your CSS code (without <style> tags). Will be injected at runtime
diff --git a/config/test.yaml b/config/test.yaml
index 7e395db04..020987920 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -32,3 +32,6 @@ transcoding:
32 480p: true 32 480p: true
33 720p: true 33 720p: true
34 1080p: true 34 1080p: true
35
36instance:
37 default_nsfw_policy: 'display' \ No newline at end of file
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 88f047adc..e47b71f44 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -46,6 +46,7 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
46 name: CONFIG.INSTANCE.NAME, 46 name: CONFIG.INSTANCE.NAME,
47 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, 47 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
48 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE, 48 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
49 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
49 customizations: { 50 customizations: {
50 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, 51 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
51 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS 52 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
@@ -128,6 +129,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
128 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota 129 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
129 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute 130 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
130 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription 131 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
132 toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
131 133
132 await writeFilePromise(CONFIG.CUSTOM_FILE, JSON.stringify(toUpdateJSON, undefined, 2)) 134 await writeFilePromise(CONFIG.CUSTOM_FILE, JSON.stringify(toUpdateJSON, undefined, 2))
133 135
@@ -153,6 +155,7 @@ function customConfig (): CustomConfig {
153 description: CONFIG.INSTANCE.DESCRIPTION, 155 description: CONFIG.INSTANCE.DESCRIPTION,
154 terms: CONFIG.INSTANCE.TERMS, 156 terms: CONFIG.INSTANCE.TERMS,
155 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE, 157 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
158 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
156 customizations: { 159 customizations: {
157 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS, 160 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS,
158 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT 161 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT
diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts
index 56cbf9448..6540adb1c 100644
--- a/server/controllers/api/users.ts
+++ b/server/controllers/api/users.ts
@@ -42,6 +42,7 @@ import { AccountVideoRateModel } from '../../models/account/account-video-rate'
42import { UserModel } from '../../models/account/user' 42import { UserModel } from '../../models/account/user'
43import { OAuthTokenModel } from '../../models/oauth/oauth-token' 43import { OAuthTokenModel } from '../../models/oauth/oauth-token'
44import { VideoModel } from '../../models/video/video' 44import { VideoModel } from '../../models/video/video'
45import { VideoSortField } from '../../../client/src/app/shared/video/sort-field.type'
45 46
46const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.AVATARS_DIR }) 47const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.AVATARS_DIR })
47const loginRateLimiter = new RateLimit({ 48const loginRateLimiter = new RateLimit({
@@ -161,7 +162,13 @@ export {
161 162
162async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 163async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
163 const user = res.locals.oauth.token.User as UserModel 164 const user = res.locals.oauth.token.User as UserModel
164 const resultList = await VideoModel.listAccountVideosForApi(user.Account.id ,req.query.start, req.query.count, req.query.sort) 165 const resultList = await VideoModel.listAccountVideosForApi(
166 user.Account.id,
167 req.query.start as number,
168 req.query.count as number,
169 req.query.sort as VideoSortField,
170 false // Display my NSFW videos
171 )
165 172
166 return res.json(getFormattedObjects(resultList.data, resultList.total)) 173 return res.json(getFormattedObjects(resultList.data, resultList.total))
167} 174}
@@ -188,7 +195,7 @@ async function createUser (req: express.Request) {
188 username: body.username, 195 username: body.username,
189 password: body.password, 196 password: body.password,
190 email: body.email, 197 email: body.email,
191 displayNSFW: false, 198 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
192 autoPlayVideo: true, 199 autoPlayVideo: true,
193 role: body.role, 200 role: body.role,
194 videoQuota: body.videoQuota 201 videoQuota: body.videoQuota
@@ -219,7 +226,7 @@ async function registerUser (req: express.Request) {
219 username: body.username, 226 username: body.username,
220 password: body.password, 227 password: body.password,
221 email: body.email, 228 email: body.email,
222 displayNSFW: false, 229 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
223 autoPlayVideo: true, 230 autoPlayVideo: true,
224 role: UserRole.USER, 231 role: UserRole.USER,
225 videoQuota: CONFIG.USER.VIDEO_QUOTA 232 videoQuota: CONFIG.USER.VIDEO_QUOTA
@@ -286,7 +293,7 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
286 293
287 if (body.password !== undefined) user.password = body.password 294 if (body.password !== undefined) user.password = body.password
288 if (body.email !== undefined) user.email = body.email 295 if (body.email !== undefined) user.email = body.email
289 if (body.displayNSFW !== undefined) user.displayNSFW = body.displayNSFW 296 if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy
290 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo 297 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
291 298
292 await sequelizeTypescript.transaction(async t => { 299 await sequelizeTypescript.transaction(async t => {
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index b4cd67158..6e8601fa1 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -19,13 +19,18 @@ import {
19 VIDEO_MIMETYPE_EXT, 19 VIDEO_MIMETYPE_EXT,
20 VIDEO_PRIVACIES 20 VIDEO_PRIVACIES
21} from '../../../initializers' 21} from '../../../initializers'
22import { fetchRemoteVideoDescription, getVideoActivityPubUrl, shareVideoByServerAndChannel } from '../../../lib/activitypub' 22import {
23 fetchRemoteVideoDescription,
24 getVideoActivityPubUrl,
25 shareVideoByServerAndChannel
26} from '../../../lib/activitypub'
23import { sendCreateVideo, sendCreateView, sendUpdateVideo } from '../../../lib/activitypub/send' 27import { sendCreateVideo, sendCreateView, sendUpdateVideo } from '../../../lib/activitypub/send'
24import { JobQueue } from '../../../lib/job-queue' 28import { JobQueue } from '../../../lib/job-queue'
25import { Redis } from '../../../lib/redis' 29import { Redis } from '../../../lib/redis'
26import { 30import {
27 asyncMiddleware, 31 asyncMiddleware,
28 authenticate, 32 authenticate,
33 optionalAuthenticate,
29 paginationValidator, 34 paginationValidator,
30 setDefaultPagination, 35 setDefaultPagination,
31 setDefaultSort, 36 setDefaultSort,
@@ -44,6 +49,9 @@ import { blacklistRouter } from './blacklist'
44import { videoChannelRouter } from './channel' 49import { videoChannelRouter } from './channel'
45import { videoCommentRouter } from './comment' 50import { videoCommentRouter } from './comment'
46import { rateVideoRouter } from './rate' 51import { rateVideoRouter } from './rate'
52import { User } from '../../../../shared/models/users'
53import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
54import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
47 55
48const videosRouter = express.Router() 56const videosRouter = express.Router()
49 57
@@ -81,6 +89,7 @@ videosRouter.get('/',
81 videosSortValidator, 89 videosSortValidator,
82 setDefaultSort, 90 setDefaultSort,
83 setDefaultPagination, 91 setDefaultPagination,
92 optionalAuthenticate,
84 asyncMiddleware(listVideos) 93 asyncMiddleware(listVideos)
85) 94)
86videosRouter.get('/search', 95videosRouter.get('/search',
@@ -89,6 +98,7 @@ videosRouter.get('/search',
89 videosSortValidator, 98 videosSortValidator,
90 setDefaultSort, 99 setDefaultSort,
91 setDefaultPagination, 100 setDefaultPagination,
101 optionalAuthenticate,
92 asyncMiddleware(searchVideos) 102 asyncMiddleware(searchVideos)
93) 103)
94videosRouter.put('/:id', 104videosRouter.put('/:id',
@@ -391,7 +401,13 @@ async function getVideoDescription (req: express.Request, res: express.Response)
391} 401}
392 402
393async function listVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 403async function listVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
394 const resultList = await VideoModel.listForApi(req.query.start, req.query.count, req.query.sort, req.query.filter) 404 const resultList = await VideoModel.listForApi(
405 req.query.start as number,
406 req.query.count as number,
407 req.query.sort as VideoSortField,
408 isNSFWHidden(res),
409 req.query.filter as VideoFilter
410 )
395 411
396 return res.json(getFormattedObjects(resultList.data, resultList.total)) 412 return res.json(getFormattedObjects(resultList.data, resultList.total))
397} 413}
@@ -419,11 +435,21 @@ async function removeVideo (req: express.Request, res: express.Response) {
419 435
420async function searchVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 436async function searchVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
421 const resultList = await VideoModel.searchAndPopulateAccountAndServer( 437 const resultList = await VideoModel.searchAndPopulateAccountAndServer(
422 req.query.search, 438 req.query.search as string,
423 req.query.start, 439 req.query.start as number,
424 req.query.count, 440 req.query.count as number,
425 req.query.sort 441 req.query.sort as VideoSortField,
442 isNSFWHidden(res)
426 ) 443 )
427 444
428 return res.json(getFormattedObjects(resultList.data, resultList.total)) 445 return res.json(getFormattedObjects(resultList.data, resultList.total))
429} 446}
447
448function isNSFWHidden (res: express.Response) {
449 if (res.locals.oauth) {
450 const user: User = res.locals.oauth.token.User
451 if (user) return user.nsfwPolicy === 'do_not_list'
452 }
453
454 return CONFIG.INSTANCE.DEFAULT_NSFW_POLICY === 'do_not_list'
455}
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 3e384c48a..27ebecc40 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -6,6 +6,7 @@ import * as Feed from 'pfeed'
6import { ResultList } from '../../shared/models' 6import { ResultList } from '../../shared/models'
7import { AccountModel } from '../models/account/account' 7import { AccountModel } from '../models/account/account'
8import { cacheRoute } from '../middlewares/cache' 8import { cacheRoute } from '../middlewares/cache'
9import { VideoSortField } from '../../client/src/app/shared/video/sort-field.type'
9 10
10const feedsRouter = express.Router() 11const feedsRouter = express.Router()
11 12
@@ -31,20 +32,22 @@ async function generateFeed (req: express.Request, res: express.Response, next:
31 32
32 let resultList: ResultList<VideoModel> 33 let resultList: ResultList<VideoModel>
33 const account: AccountModel = res.locals.account 34 const account: AccountModel = res.locals.account
35 const hideNSFW = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY === 'do_not_list'
34 36
35 if (account) { 37 if (account) {
36 resultList = await VideoModel.listAccountVideosForApi( 38 resultList = await VideoModel.listAccountVideosForApi(
37 account.id, 39 account.id,
38 start, 40 start,
39 FEEDS.COUNT, 41 FEEDS.COUNT,
40 req.query.sort, 42 req.query.sort as VideoSortField,
41 true 43 hideNSFW
42 ) 44 )
43 } else { 45 } else {
44 resultList = await VideoModel.listForApi( 46 resultList = await VideoModel.listForApi(
45 start, 47 start,
46 FEEDS.COUNT, 48 FEEDS.COUNT,
47 req.query.sort, 49 req.query.sort as VideoSortField,
50 hideNSFW,
48 req.query.filter, 51 req.query.filter,
49 true 52 true
50 ) 53 )
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts
index bbc7cc199..c0acb8218 100644
--- a/server/helpers/custom-validators/users.ts
+++ b/server/helpers/custom-validators/users.ts
@@ -1,9 +1,10 @@
1import 'express-validator' 1import 'express-validator'
2import * as validator from 'validator' 2import * as validator from 'validator'
3import { UserRole } from '../../../shared' 3import { UserRole } from '../../../shared'
4import { CONSTRAINTS_FIELDS } from '../../initializers' 4import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers'
5 5
6import { exists, isFileValid } from './misc' 6import { exists, isFileValid } from './misc'
7import { values } from 'lodash'
7 8
8const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS 9const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS
9 10
@@ -29,8 +30,9 @@ function isBoolean (value: any) {
29 return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value)) 30 return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
30} 31}
31 32
32function isUserDisplayNSFWValid (value: any) { 33const nsfwPolicies = values(NSFW_POLICY_TYPES)
33 return isBoolean(value) 34function isUserNSFWPolicyValid (value: any) {
35 return exists(value) && nsfwPolicies.indexOf(value) !== -1
34} 36}
35 37
36function isUserAutoPlayVideoValid (value: any) { 38function isUserAutoPlayVideoValid (value: any) {
@@ -56,7 +58,7 @@ export {
56 isUserRoleValid, 58 isUserRoleValid,
57 isUserVideoQuotaValid, 59 isUserVideoQuotaValid,
58 isUserUsernameValid, 60 isUserUsernameValid,
59 isUserDisplayNSFWValid, 61 isUserNSFWPolicyValid,
60 isUserAutoPlayVideoValid, 62 isUserAutoPlayVideoValid,
61 isUserDescriptionValid, 63 isUserDescriptionValid,
62 isAvatarFile 64 isAvatarFile
diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts
index 71f303963..739f623c6 100644
--- a/server/initializers/checker.ts
+++ b/server/initializers/checker.ts
@@ -5,12 +5,12 @@ import { ApplicationModel } from '../models/application/application'
5import { OAuthClientModel } from '../models/oauth/oauth-client' 5import { OAuthClientModel } from '../models/oauth/oauth-client'
6 6
7// Some checks on configuration files 7// Some checks on configuration files
8// Return an error message, or null if everything is okay
8function checkConfig () { 9function checkConfig () {
9 if (config.has('webserver.host')) { 10 const defaultNSFWPolicy = config.get<string>('instance.default_nsfw_policy')
10 let errorMessage = '`host` config key was renamed to `hostname` but it seems you still have a `host` key in your configuration files!'
11 errorMessage += ' Please ensure to rename your `host` configuration to `hostname`.'
12 11
13 return errorMessage 12 if ([ 'do_not_list', 'blur', 'display' ].indexOf(defaultNSFWPolicy) === -1) {
13 return 'NSFW policy setting should be "do_not_list" or "blur" or "display" instead of ' + defaultNSFWPolicy
14 } 14 }
15 15
16 return null 16 return null
@@ -28,7 +28,8 @@ function checkMissedConfig () {
28 'log.level', 28 'log.level',
29 'user.video_quota', 29 'user.video_quota',
30 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 30 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads',
31 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route' 31 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
32 'instance.default_nsfw_policy'
32 ] 33 ]
33 const miss: string[] = [] 34 const miss: string[] = []
34 35
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 5ee13389d..d1915586a 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -6,13 +6,14 @@ import { FollowState } from '../../shared/models/actors'
6import { VideoPrivacy } from '../../shared/models/videos' 6import { VideoPrivacy } from '../../shared/models/videos'
7// Do not use barrels, remain constants as independent as possible 7// Do not use barrels, remain constants as independent as possible
8import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' 8import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
9 10
10// Use a variable to reload the configuration if we need 11// Use a variable to reload the configuration if we need
11let config: IConfig = require('config') 12let config: IConfig = require('config')
12 13
13// --------------------------------------------------------------------------- 14// ---------------------------------------------------------------------------
14 15
15const LAST_MIGRATION_VERSION = 200 16const LAST_MIGRATION_VERSION = 205
16 17
17// --------------------------------------------------------------------------- 18// ---------------------------------------------------------------------------
18 19
@@ -167,6 +168,7 @@ const CONFIG = {
167 get DESCRIPTION () { return config.get<string>('instance.description') }, 168 get DESCRIPTION () { return config.get<string>('instance.description') },
168 get TERMS () { return config.get<string>('instance.terms') }, 169 get TERMS () { return config.get<string>('instance.terms') },
169 get DEFAULT_CLIENT_ROUTE () { return config.get<string>('instance.default_client_route') }, 170 get DEFAULT_CLIENT_ROUTE () { return config.get<string>('instance.default_client_route') },
171 get DEFAULT_NSFW_POLICY () { return config.get<NSFWPolicyType>('instance.default_nsfw_policy') },
170 CUSTOMIZATIONS: { 172 CUSTOMIZATIONS: {
171 get JAVASCRIPT () { return config.get<string>('instance.customizations.javascript') }, 173 get JAVASCRIPT () { return config.get<string>('instance.customizations.javascript') },
172 get CSS () { return config.get<string>('instance.customizations.css') } 174 get CSS () { return config.get<string>('instance.customizations.css') }
@@ -378,6 +380,12 @@ const BCRYPT_SALT_SIZE = 10
378 380
379const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes 381const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
380 382
383const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = {
384 DO_NOT_LIST: 'do_not_list',
385 BLUR: 'blur',
386 DISPLAY: 'display'
387}
388
381// --------------------------------------------------------------------------- 389// ---------------------------------------------------------------------------
382 390
383// Express static paths (router) 391// Express static paths (router)
@@ -474,6 +482,7 @@ export {
474 PRIVATE_RSA_KEY_SIZE, 482 PRIVATE_RSA_KEY_SIZE,
475 SORTABLE_COLUMNS, 483 SORTABLE_COLUMNS,
476 FEEDS, 484 FEEDS,
485 NSFW_POLICY_TYPES,
477 STATIC_MAX_AGE, 486 STATIC_MAX_AGE,
478 STATIC_PATHS, 487 STATIC_PATHS,
479 ACTIVITY_PUB, 488 ACTIVITY_PUB,
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index 09c6d5473..b0084b368 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -120,6 +120,7 @@ async function createOAuthAdminIfNotExist () {
120 email, 120 email,
121 password, 121 password,
122 role, 122 role,
123 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
123 videoQuota: -1 124 videoQuota: -1
124 } 125 }
125 const user = new UserModel(userData) 126 const user = new UserModel(userData)
diff --git a/server/initializers/migrations/0205-user-nsfw-policy.ts b/server/initializers/migrations/0205-user-nsfw-policy.ts
new file mode 100644
index 000000000..d0f6e8962
--- /dev/null
+++ b/server/initializers/migrations/0205-user-nsfw-policy.ts
@@ -0,0 +1,46 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const data = {
11 type: Sequelize.ENUM('do_not_list', 'blur', 'display'),
12 allowNull: true,
13 defaultValue: null
14 }
15 await utils.queryInterface.addColumn('user', 'nsfwPolicy', data)
16 }
17
18 {
19 const query = 'UPDATE "user" SET "nsfwPolicy" = \'do_not_list\''
20 await utils.sequelize.query(query)
21 }
22
23 {
24 const query = 'UPDATE "user" SET "nsfwPolicy" = \'display\' WHERE "displayNSFW" = true'
25 await utils.sequelize.query(query)
26 }
27
28 {
29 const query = 'ALTER TABLE "user" ALTER COLUMN "nsfwPolicy" SET NOT NULL'
30 await utils.sequelize.query(query)
31 }
32
33 {
34 await utils.queryInterface.removeColumn('user', 'displayNSFW')
35 }
36
37}
38
39function down (options) {
40 throw new Error('Not implemented.')
41}
42
43export {
44 up,
45 down
46}
diff --git a/server/middlewares/oauth.ts b/server/middlewares/oauth.ts
index 41a3fb718..a6f28dd5b 100644
--- a/server/middlewares/oauth.ts
+++ b/server/middlewares/oauth.ts
@@ -2,6 +2,7 @@ import * as express from 'express'
2import * as OAuthServer from 'express-oauth-server' 2import * as OAuthServer from 'express-oauth-server'
3import 'express-validator' 3import 'express-validator'
4import { OAUTH_LIFETIME } from '../initializers' 4import { OAUTH_LIFETIME } from '../initializers'
5import { logger } from '../helpers/logger'
5 6
6const oAuthServer = new OAuthServer({ 7const oAuthServer = new OAuthServer({
7 useErrorHandler: true, 8 useErrorHandler: true,
@@ -13,6 +14,8 @@ const oAuthServer = new OAuthServer({
13function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { 14function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
14 oAuthServer.authenticate()(req, res, err => { 15 oAuthServer.authenticate()(req, res, err => {
15 if (err) { 16 if (err) {
17 logger.warn('Cannot authenticate.', { err })
18
16 return res.status(err.status) 19 return res.status(err.status)
17 .json({ 20 .json({
18 error: 'Token is invalid.', 21 error: 'Token is invalid.',
@@ -25,6 +28,12 @@ function authenticate (req: express.Request, res: express.Response, next: expres
25 }) 28 })
26} 29}
27 30
31function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
32 if (req.header('authorization')) return authenticate(req, res, next)
33
34 return next()
35}
36
28function token (req: express.Request, res: express.Response, next: express.NextFunction) { 37function token (req: express.Request, res: express.Response, next: express.NextFunction) {
29 return oAuthServer.token()(req, res, err => { 38 return oAuthServer.token()(req, res, err => {
30 if (err) { 39 if (err) {
@@ -44,5 +53,6 @@ function token (req: express.Request, res: express.Response, next: express.NextF
44 53
45export { 54export {
46 authenticate, 55 authenticate,
56 optionalAuthenticate,
47 token 57 token
48} 58}
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index ee6f6efa4..f58c0676c 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -1,6 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator/check' 2import { body } from 'express-validator/check'
3import { isUserVideoQuotaValid } from '../../helpers/custom-validators/users' 3import { isUserNSFWPolicyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6 6
@@ -9,6 +9,7 @@ const customConfigUpdateValidator = [
9 body('instance.description').exists().withMessage('Should have a valid instance description'), 9 body('instance.description').exists().withMessage('Should have a valid instance description'),
10 body('instance.terms').exists().withMessage('Should have a valid instance terms'), 10 body('instance.terms').exists().withMessage('Should have a valid instance terms'),
11 body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'), 11 body('instance.defaultClientRoute').exists().withMessage('Should have a valid instance default client route'),
12 body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid).withMessage('Should have a valid NSFW policy'),
12 body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'), 13 body('instance.customizations.css').exists().withMessage('Should have a valid instance CSS customization'),
13 body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'), 14 body('instance.customizations.javascript').exists().withMessage('Should have a valid instance JavaScript customization'),
14 body('cache.previews.size').isInt().withMessage('Should have a valid previews size'), 15 body('cache.previews.size').isInt().withMessage('Should have a valid previews size'),
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 6ea3d0b6c..5dd8caa3f 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -8,7 +8,7 @@ import {
8 isAvatarFile, 8 isAvatarFile,
9 isUserAutoPlayVideoValid, 9 isUserAutoPlayVideoValid,
10 isUserDescriptionValid, 10 isUserDescriptionValid,
11 isUserDisplayNSFWValid, 11 isUserNSFWPolicyValid,
12 isUserPasswordValid, 12 isUserPasswordValid,
13 isUserRoleValid, 13 isUserRoleValid,
14 isUserUsernameValid, 14 isUserUsernameValid,
@@ -101,7 +101,7 @@ const usersUpdateMeValidator = [
101 body('description').optional().custom(isUserDescriptionValid).withMessage('Should have a valid description'), 101 body('description').optional().custom(isUserDescriptionValid).withMessage('Should have a valid description'),
102 body('password').optional().custom(isUserPasswordValid).withMessage('Should have a valid password'), 102 body('password').optional().custom(isUserPasswordValid).withMessage('Should have a valid password'),
103 body('email').optional().isEmail().withMessage('Should have a valid email attribute'), 103 body('email').optional().isEmail().withMessage('Should have a valid email attribute'),
104 body('displayNSFW').optional().custom(isUserDisplayNSFWValid).withMessage('Should have a valid display Not Safe For Work attribute'), 104 body('nsfwPolicy').optional().custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'),
105 body('autoPlayVideo').optional().custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'), 105 body('autoPlayVideo').optional().custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
106 106
107 (req: express.Request, res: express.Response, next: express.NextFunction) => { 107 (req: express.Request, res: express.Response, next: express.NextFunction) => {
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 8afd246b2..56af2f30a 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -21,7 +21,7 @@ import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
21import { User, UserRole } from '../../../shared/models/users' 21import { User, UserRole } from '../../../shared/models/users'
22import { 22import {
23 isUserAutoPlayVideoValid, 23 isUserAutoPlayVideoValid,
24 isUserDisplayNSFWValid, 24 isUserNSFWPolicyValid,
25 isUserPasswordValid, 25 isUserPasswordValid,
26 isUserRoleValid, 26 isUserRoleValid,
27 isUserUsernameValid, 27 isUserUsernameValid,
@@ -32,6 +32,9 @@ import { OAuthTokenModel } from '../oauth/oauth-token'
32import { getSort, throwIfNotValid } from '../utils' 32import { getSort, throwIfNotValid } from '../utils'
33import { VideoChannelModel } from '../video/video-channel' 33import { VideoChannelModel } from '../video/video-channel'
34import { AccountModel } from './account' 34import { AccountModel } from './account'
35import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
36import { values } from 'lodash'
37import { NSFW_POLICY_TYPES } from '../../initializers'
35 38
36@DefaultScope({ 39@DefaultScope({
37 include: [ 40 include: [
@@ -83,10 +86,9 @@ export class UserModel extends Model<UserModel> {
83 email: string 86 email: string
84 87
85 @AllowNull(false) 88 @AllowNull(false)
86 @Default(false) 89 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
87 @Is('UserDisplayNSFW', value => throwIfNotValid(value, isUserDisplayNSFWValid, 'display NSFW boolean')) 90 @Column(DataType.ENUM(values(NSFW_POLICY_TYPES)))
88 @Column 91 nsfwPolicy: NSFWPolicyType
89 displayNSFW: boolean
90 92
91 @AllowNull(false) 93 @AllowNull(false)
92 @Default(true) 94 @Default(true)
@@ -265,7 +267,7 @@ export class UserModel extends Model<UserModel> {
265 id: this.id, 267 id: this.id,
266 username: this.username, 268 username: this.username,
267 email: this.email, 269 email: this.email,
268 displayNSFW: this.displayNSFW, 270 nsfwPolicy: this.nsfwPolicy,
269 autoPlayVideo: this.autoPlayVideo, 271 autoPlayVideo: this.autoPlayVideo,
270 role: this.role, 272 role: this.role,
271 roleLabel: USER_ROLE_LABELS[ this.role ], 273 roleLabel: USER_ROLE_LABELS[ this.role ],
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index a7923b477..2e66f9aa7 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -95,7 +95,7 @@ enum ScopeNames {
95} 95}
96 96
97@Scopes({ 97@Scopes({
98 [ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter, withFiles?: boolean) => { 98 [ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, hideNSFW: boolean, filter?: VideoFilter, withFiles?: boolean) => {
99 const query: IFindOptions<VideoModel> = { 99 const query: IFindOptions<VideoModel> = {
100 where: { 100 where: {
101 id: { 101 id: {
@@ -161,6 +161,11 @@ enum ScopeNames {
161 }) 161 })
162 } 162 }
163 163
164 // Hide nsfw videos?
165 if (hideNSFW === true) {
166 query.where['nsfw'] = false
167 }
168
164 return query 169 return query
165 }, 170 },
166 [ScopeNames.WITH_ACCOUNT_DETAILS]: { 171 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
@@ -640,7 +645,7 @@ export class VideoModel extends Model<VideoModel> {
640 }) 645 })
641 } 646 }
642 647
643 static listAccountVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) { 648 static listAccountVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) {
644 const query: IFindOptions<VideoModel> = { 649 const query: IFindOptions<VideoModel> = {
645 offset: start, 650 offset: start,
646 limit: count, 651 limit: count,
@@ -669,6 +674,12 @@ export class VideoModel extends Model<VideoModel> {
669 }) 674 })
670 } 675 }
671 676
677 if (hideNSFW === true) {
678 query.where = {
679 nsfw: false
680 }
681 }
682
672 return VideoModel.findAndCountAll(query).then(({ rows, count }) => { 683 return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
673 return { 684 return {
674 data: rows, 685 data: rows,
@@ -677,7 +688,7 @@ export class VideoModel extends Model<VideoModel> {
677 }) 688 })
678 } 689 }
679 690
680 static async listForApi (start: number, count: number, sort: string, filter?: VideoFilter, withFiles = false) { 691 static async listForApi (start: number, count: number, sort: string, hideNSFW: boolean, filter?: VideoFilter, withFiles = false) {
681 const query = { 692 const query = {
682 offset: start, 693 offset: start,
683 limit: count, 694 limit: count,
@@ -685,8 +696,7 @@ export class VideoModel extends Model<VideoModel> {
685 } 696 }
686 697
687 const serverActor = await getServerActor() 698 const serverActor = await getServerActor()
688 699 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, hideNSFW, filter, withFiles ] })
689 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, filter, withFiles ] })
690 .findAndCountAll(query) 700 .findAndCountAll(query)
691 .then(({ rows, count }) => { 701 .then(({ rows, count }) => {
692 return { 702 return {
@@ -696,7 +706,7 @@ export class VideoModel extends Model<VideoModel> {
696 }) 706 })
697 } 707 }
698 708
699 static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string) { 709 static async searchAndPopulateAccountAndServer (value: string, start: number, count: number, sort: string, hideNSFW: boolean) {
700 const query: IFindOptions<VideoModel> = { 710 const query: IFindOptions<VideoModel> = {
701 offset: start, 711 offset: start,
702 limit: count, 712 limit: count,
@@ -724,7 +734,7 @@ export class VideoModel extends Model<VideoModel> {
724 734
725 const serverActor = await getServerActor() 735 const serverActor = await getServerActor()
726 736
727 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id ] }) 737 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, hideNSFW ] })
728 .findAndCountAll(query) 738 .findAndCountAll(query)
729 .then(({ rows, count }) => { 739 .then(({ rows, count }) => {
730 return { 740 return {
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 3fe517fad..58b780f38 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -6,7 +6,7 @@ import { CustomConfig } from '../../../../shared/models/server/custom-config.mod
6 6
7import { 7import {
8 createUser, flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePutBodyRequest, runServer, ServerInfo, 8 createUser, flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePutBodyRequest, runServer, ServerInfo,
9 setAccessTokensToServers, userLogin 9 setAccessTokensToServers, userLogin, immutableAssign
10} from '../../utils' 10} from '../../utils'
11 11
12describe('Test config API validators', function () { 12describe('Test config API validators', function () {
@@ -20,6 +20,7 @@ describe('Test config API validators', function () {
20 description: 'my super description', 20 description: 'my super description',
21 terms: 'my super terms', 21 terms: 'my super terms',
22 defaultClientRoute: '/videos/recently-added', 22 defaultClientRoute: '/videos/recently-added',
23 defaultNSFWPolicy: 'blur',
23 customizations: { 24 customizations: {
24 javascript: 'alert("coucou")', 25 javascript: 'alert("coucou")',
25 css: 'body { background-color: red; }' 26 css: 'body { background-color: red; }'
@@ -122,6 +123,22 @@ describe('Test config API validators', function () {
122 }) 123 })
123 }) 124 })
124 125
126 it('Should fail with a bad default NSFW policy', async function () {
127 const newUpdateParams = immutableAssign(updateParams, {
128 instance: {
129 defaultNSFWPolicy: 'hello'
130 }
131 })
132
133 await makePutBodyRequest({
134 url: server.url,
135 path,
136 fields: newUpdateParams,
137 token: server.accessToken,
138 statusCodeExpected: 400
139 })
140 })
141
125 it('Should success with the correct parameters', async function () { 142 it('Should success with the correct parameters', async function () {
126 await makePutBodyRequest({ 143 await makePutBodyRequest({
127 url: server.url, 144 url: server.url,
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index a3e415b94..e8a6ffd19 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -231,9 +231,9 @@ describe('Test users API validators', function () {
231 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields }) 231 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
232 }) 232 })
233 233
234 it('Should fail with an invalid display NSFW attribute', async function () { 234 it('Should fail with an invalid NSFW policy attribute', async function () {
235 const fields = { 235 const fields = {
236 displayNSFW: -1 236 nsfwPolicy: 'hello'
237 } 237 }
238 238
239 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields }) 239 await makePutBodyRequest({ url: server.url, path: path + 'me', token: userAccessToken, fields })
@@ -266,7 +266,7 @@ describe('Test users API validators', function () {
266 it('Should succeed with the correct params', async function () { 266 it('Should succeed with the correct params', async function () {
267 const fields = { 267 const fields = {
268 password: 'my super password', 268 password: 'my super password',
269 displayNSFW: true, 269 nsfwPolicy: 'blur',
270 autoPlayVideo: false, 270 autoPlayVideo: false,
271 email: 'super_email@example.com' 271 email: 'super_email@example.com'
272 } 272 }
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index e17588142..3f1b1532c 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -59,6 +59,7 @@ describe('Test config', function () {
59 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!') 59 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
60 expect(data.instance.terms).to.equal('No terms for now.') 60 expect(data.instance.terms).to.equal('No terms for now.')
61 expect(data.instance.defaultClientRoute).to.equal('/videos/trending') 61 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
62 expect(data.instance.defaultNSFWPolicy).to.equal('display')
62 expect(data.instance.customizations.css).to.be.empty 63 expect(data.instance.customizations.css).to.be.empty
63 expect(data.instance.customizations.javascript).to.be.empty 64 expect(data.instance.customizations.javascript).to.be.empty
64 expect(data.cache.previews.size).to.equal(1) 65 expect(data.cache.previews.size).to.equal(1)
@@ -83,6 +84,7 @@ describe('Test config', function () {
83 description: 'my super description', 84 description: 'my super description',
84 terms: 'my super terms', 85 terms: 'my super terms',
85 defaultClientRoute: '/videos/recently-added', 86 defaultClientRoute: '/videos/recently-added',
87 defaultNSFWPolicy: 'blur' as 'blur',
86 customizations: { 88 customizations: {
87 javascript: 'alert("coucou")', 89 javascript: 'alert("coucou")',
88 css: 'body { background-color: red; }' 90 css: 'body { background-color: red; }'
@@ -125,6 +127,7 @@ describe('Test config', function () {
125 expect(data.instance.description).to.equal('my super description') 127 expect(data.instance.description).to.equal('my super description')
126 expect(data.instance.terms).to.equal('my super terms') 128 expect(data.instance.terms).to.equal('my super terms')
127 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added') 129 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
130 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
128 expect(data.instance.customizations.javascript).to.equal('alert("coucou")') 131 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
129 expect(data.instance.customizations.css).to.equal('body { background-color: red; }') 132 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
130 expect(data.cache.previews.size).to.equal(2) 133 expect(data.cache.previews.size).to.equal(2)
@@ -156,6 +159,7 @@ describe('Test config', function () {
156 expect(data.instance.description).to.equal('my super description') 159 expect(data.instance.description).to.equal('my super description')
157 expect(data.instance.terms).to.equal('my super terms') 160 expect(data.instance.terms).to.equal('my super terms')
158 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added') 161 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
162 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
159 expect(data.instance.customizations.javascript).to.equal('alert("coucou")') 163 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
160 expect(data.instance.customizations.css).to.equal('body { background-color: red; }') 164 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
161 expect(data.cache.previews.size).to.equal(2) 165 expect(data.cache.previews.size).to.equal(2)
@@ -198,6 +202,7 @@ describe('Test config', function () {
198 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!') 202 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
199 expect(data.instance.terms).to.equal('No terms for now.') 203 expect(data.instance.terms).to.equal('No terms for now.')
200 expect(data.instance.defaultClientRoute).to.equal('/videos/trending') 204 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
205 expect(data.instance.defaultNSFWPolicy).to.equal('display')
201 expect(data.instance.customizations.css).to.be.empty 206 expect(data.instance.customizations.css).to.be.empty
202 expect(data.instance.customizations.javascript).to.be.empty 207 expect(data.instance.customizations.javascript).to.be.empty
203 expect(data.cache.previews.size).to.equal(1) 208 expect(data.cache.previews.size).to.equal(1)
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index b6ab4f660..1192ef9e4 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -168,7 +168,7 @@ describe('Test users', function () {
168 168
169 expect(user.username).to.equal('user_1') 169 expect(user.username).to.equal('user_1')
170 expect(user.email).to.equal('user_1@example.com') 170 expect(user.email).to.equal('user_1@example.com')
171 expect(user.displayNSFW).to.be.false 171 expect(user.nsfwPolicy).to.equal('display')
172 expect(user.videoQuota).to.equal(2 * 1024 * 1024) 172 expect(user.videoQuota).to.equal(2 * 1024 * 1024)
173 expect(user.roleLabel).to.equal('User') 173 expect(user.roleLabel).to.equal('User')
174 expect(user.id).to.be.a('number') 174 expect(user.id).to.be.a('number')
@@ -215,12 +215,12 @@ describe('Test users', function () {
215 const user = users[ 0 ] 215 const user = users[ 0 ]
216 expect(user.username).to.equal('user_1') 216 expect(user.username).to.equal('user_1')
217 expect(user.email).to.equal('user_1@example.com') 217 expect(user.email).to.equal('user_1@example.com')
218 expect(user.displayNSFW).to.be.false 218 expect(user.nsfwPolicy).to.equal('display')
219 219
220 const rootUser = users[ 1 ] 220 const rootUser = users[ 1 ]
221 expect(rootUser.username).to.equal('root') 221 expect(rootUser.username).to.equal('root')
222 expect(rootUser.email).to.equal('admin1@example.com') 222 expect(rootUser.email).to.equal('admin1@example.com')
223 expect(rootUser.displayNSFW).to.be.false 223 expect(user.nsfwPolicy).to.equal('display')
224 224
225 userId = user.id 225 userId = user.id
226 }) 226 })
@@ -239,7 +239,7 @@ describe('Test users', function () {
239 expect(user.username).to.equal('root') 239 expect(user.username).to.equal('root')
240 expect(user.email).to.equal('admin1@example.com') 240 expect(user.email).to.equal('admin1@example.com')
241 expect(user.roleLabel).to.equal('Administrator') 241 expect(user.roleLabel).to.equal('Administrator')
242 expect(user.displayNSFW).to.be.false 242 expect(user.nsfwPolicy).to.equal('display')
243 }) 243 })
244 244
245 it('Should list only the first user by username desc', async function () { 245 it('Should list only the first user by username desc', async function () {
@@ -254,7 +254,7 @@ describe('Test users', function () {
254 const user = users[ 0 ] 254 const user = users[ 0 ]
255 expect(user.username).to.equal('user_1') 255 expect(user.username).to.equal('user_1')
256 expect(user.email).to.equal('user_1@example.com') 256 expect(user.email).to.equal('user_1@example.com')
257 expect(user.displayNSFW).to.be.false 257 expect(user.nsfwPolicy).to.equal('display')
258 }) 258 })
259 259
260 it('Should list only the second user by createdAt desc', async function () { 260 it('Should list only the second user by createdAt desc', async function () {
@@ -269,7 +269,7 @@ describe('Test users', function () {
269 const user = users[ 0 ] 269 const user = users[ 0 ]
270 expect(user.username).to.equal('user_1') 270 expect(user.username).to.equal('user_1')
271 expect(user.email).to.equal('user_1@example.com') 271 expect(user.email).to.equal('user_1@example.com')
272 expect(user.displayNSFW).to.be.false 272 expect(user.nsfwPolicy).to.equal('display')
273 }) 273 })
274 274
275 it('Should list all the users by createdAt asc', async function () { 275 it('Should list all the users by createdAt asc', async function () {
@@ -283,11 +283,11 @@ describe('Test users', function () {
283 283
284 expect(users[ 0 ].username).to.equal('root') 284 expect(users[ 0 ].username).to.equal('root')
285 expect(users[ 0 ].email).to.equal('admin1@example.com') 285 expect(users[ 0 ].email).to.equal('admin1@example.com')
286 expect(users[ 0 ].displayNSFW).to.be.false 286 expect(users[ 0 ].nsfwPolicy).to.equal('display')
287 287
288 expect(users[ 1 ].username).to.equal('user_1') 288 expect(users[ 1 ].username).to.equal('user_1')
289 expect(users[ 1 ].email).to.equal('user_1@example.com') 289 expect(users[ 1 ].email).to.equal('user_1@example.com')
290 expect(users[ 1 ].displayNSFW).to.be.false 290 expect(users[ 1 ].nsfwPolicy).to.equal('display')
291 }) 291 })
292 292
293 it('Should update my password', async function () { 293 it('Should update my password', async function () {
@@ -305,7 +305,7 @@ describe('Test users', function () {
305 await updateMyUser({ 305 await updateMyUser({
306 url: server.url, 306 url: server.url,
307 accessToken: accessTokenUser, 307 accessToken: accessTokenUser,
308 displayNSFW: true 308 nsfwPolicy: 'do_not_list'
309 }) 309 })
310 310
311 const res = await getMyUserInformation(server.url, accessTokenUser) 311 const res = await getMyUserInformation(server.url, accessTokenUser)
@@ -313,7 +313,7 @@ describe('Test users', function () {
313 313
314 expect(user.username).to.equal('user_1') 314 expect(user.username).to.equal('user_1')
315 expect(user.email).to.equal('user_1@example.com') 315 expect(user.email).to.equal('user_1@example.com')
316 expect(user.displayNSFW).to.be.ok 316 expect(user.nsfwPolicy).to.equal('do_not_list')
317 expect(user.videoQuota).to.equal(2 * 1024 * 1024) 317 expect(user.videoQuota).to.equal(2 * 1024 * 1024)
318 expect(user.id).to.be.a('number') 318 expect(user.id).to.be.a('number')
319 expect(user.account.description).to.be.null 319 expect(user.account.description).to.be.null
@@ -344,7 +344,7 @@ describe('Test users', function () {
344 344
345 expect(user.username).to.equal('user_1') 345 expect(user.username).to.equal('user_1')
346 expect(user.email).to.equal('updated@example.com') 346 expect(user.email).to.equal('updated@example.com')
347 expect(user.displayNSFW).to.be.ok 347 expect(user.nsfwPolicy).to.equal('do_not_list')
348 expect(user.videoQuota).to.equal(2 * 1024 * 1024) 348 expect(user.videoQuota).to.equal(2 * 1024 * 1024)
349 expect(user.id).to.be.a('number') 349 expect(user.id).to.be.a('number')
350 expect(user.account.description).to.be.null 350 expect(user.account.description).to.be.null
@@ -377,7 +377,7 @@ describe('Test users', function () {
377 377
378 expect(user.username).to.equal('user_1') 378 expect(user.username).to.equal('user_1')
379 expect(user.email).to.equal('updated@example.com') 379 expect(user.email).to.equal('updated@example.com')
380 expect(user.displayNSFW).to.be.ok 380 expect(user.nsfwPolicy).to.equal('do_not_list')
381 expect(user.videoQuota).to.equal(2 * 1024 * 1024) 381 expect(user.videoQuota).to.equal(2 * 1024 * 1024)
382 expect(user.id).to.be.a('number') 382 expect(user.id).to.be.a('number')
383 expect(user.account.description).to.equal('my super description updated') 383 expect(user.account.description).to.equal('my super description updated')
@@ -398,7 +398,7 @@ describe('Test users', function () {
398 398
399 expect(user.username).to.equal('user_1') 399 expect(user.username).to.equal('user_1')
400 expect(user.email).to.equal('updated2@example.com') 400 expect(user.email).to.equal('updated2@example.com')
401 expect(user.displayNSFW).to.be.ok 401 expect(user.nsfwPolicy).to.equal('do_not_list')
402 expect(user.videoQuota).to.equal(42) 402 expect(user.videoQuota).to.equal(42)
403 expect(user.roleLabel).to.equal('Moderator') 403 expect(user.roleLabel).to.equal('Moderator')
404 expect(user.id).to.be.a('number') 404 expect(user.id).to.be.a('number')
diff --git a/server/tests/api/videos/video-nsfw.ts b/server/tests/api/videos/video-nsfw.ts
new file mode 100644
index 000000000..4e5ab11ce
--- /dev/null
+++ b/server/tests/api/videos/video-nsfw.ts
@@ -0,0 +1,197 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { flushTests, getVideosList, killallServers, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils/index'
6import { userLogin } from '../../utils/users/login'
7import { createUser } from '../../utils/users/users'
8import { getMyVideos } from '../../utils/videos/videos'
9import {
10 getConfig, getCustomConfig,
11 getMyUserInformation,
12 getVideosListWithToken,
13 runServer,
14 searchVideo,
15 searchVideoWithToken, updateCustomConfig,
16 updateMyUser
17} from '../../utils'
18import { ServerConfig } from '../../../../shared/models'
19import { CustomConfig } from '../../../../shared/models/server/custom-config.model'
20
21const expect = chai.expect
22
23describe('Test video NSFW policy', function () {
24 let server: ServerInfo
25 let userAccessToken: string
26 let customConfig: CustomConfig
27
28 before(async function () {
29 this.timeout(50000)
30
31 await flushTests()
32 server = await runServer(1)
33
34 // Get the access tokens
35 await setAccessTokensToServers([ server ])
36
37 {
38 const attributes = { name: 'nsfw', nsfw: true }
39 await uploadVideo(server.url, server.accessToken, attributes)
40 }
41
42 {
43 const attributes = { name: 'normal', nsfw: false }
44 await uploadVideo(server.url, server.accessToken, attributes)
45 }
46
47 {
48 const res = await getCustomConfig(server.url, server.accessToken)
49 customConfig = res.body
50 }
51 })
52
53 describe('Instance default NSFW policy', function () {
54 it('Should display NSFW videos with display default NSFW policy', async function () {
55 const resConfig = await getConfig(server.url)
56 const serverConfig: ServerConfig = resConfig.body
57 expect(serverConfig.instance.defaultNSFWPolicy).to.equal('display')
58
59 for (const res of [ await getVideosList(server.url), await searchVideo(server.url, 'n') ]) {
60 expect(res.body.total).to.equal(2)
61
62 const videos = res.body.data
63 expect(videos).to.have.lengthOf(2)
64 expect(videos[ 0 ].name).to.equal('normal')
65 expect(videos[ 1 ].name).to.equal('nsfw')
66 }
67 })
68
69 it('Should not display NSFW videos with do_not_list default NSFW policy', async function () {
70 customConfig.instance.defaultNSFWPolicy = 'do_not_list'
71 await updateCustomConfig(server.url, server.accessToken, customConfig)
72
73 const resConfig = await getConfig(server.url)
74 const serverConfig: ServerConfig = resConfig.body
75 expect(serverConfig.instance.defaultNSFWPolicy).to.equal('do_not_list')
76
77 for (const res of [ await getVideosList(server.url), await searchVideo(server.url, 'n') ]) {
78 expect(res.body.total).to.equal(1)
79
80 const videos = res.body.data
81 expect(videos).to.have.lengthOf(1)
82 expect(videos[ 0 ].name).to.equal('normal')
83 }
84 })
85
86 it('Should display NSFW videos with blur default NSFW policy', async function () {
87 customConfig.instance.defaultNSFWPolicy = 'blur'
88 await updateCustomConfig(server.url, server.accessToken, customConfig)
89
90 const resConfig = await getConfig(server.url)
91 const serverConfig: ServerConfig = resConfig.body
92 expect(serverConfig.instance.defaultNSFWPolicy).to.equal('blur')
93
94 for (const res of [ await getVideosList(server.url), await searchVideo(server.url, 'n') ]) {
95 expect(res.body.total).to.equal(2)
96
97 const videos = res.body.data
98 expect(videos).to.have.lengthOf(2)
99 expect(videos[ 0 ].name).to.equal('normal')
100 expect(videos[ 1 ].name).to.equal('nsfw')
101 }
102 })
103 })
104
105 describe('User NSFW policy', function () {
106
107 it('Should create a user having the default nsfw policy', async function () {
108 const username = 'user1'
109 const password = 'my super password'
110 await createUser(server.url, server.accessToken, username, password)
111
112 userAccessToken = await userLogin(server, { username, password })
113
114 const res = await getMyUserInformation(server.url, userAccessToken)
115 const user = res.body
116
117 expect(user.nsfwPolicy).to.equal('blur')
118 })
119
120 it('Should display NSFW videos with blur user NSFW policy', async function () {
121 const results = [
122 await getVideosListWithToken(server.url, userAccessToken),
123 await searchVideoWithToken(server.url, 'n', userAccessToken)
124 ]
125
126 for (const res of results) {
127 expect(res.body.total).to.equal(2)
128
129 const videos = res.body.data
130 expect(videos).to.have.lengthOf(2)
131 expect(videos[ 0 ].name).to.equal('normal')
132 expect(videos[ 1 ].name).to.equal('nsfw')
133 }
134 })
135
136 it('Should display NSFW videos with display user NSFW policy', async function () {
137 await updateMyUser({
138 url: server.url,
139 accessToken: server.accessToken,
140 nsfwPolicy: 'display'
141 })
142
143 const results = [
144 await getVideosListWithToken(server.url, server.accessToken),
145 await searchVideoWithToken(server.url, 'n', server.accessToken)
146 ]
147
148 for (const res of results) {
149 expect(res.body.total).to.equal(2)
150
151 const videos = res.body.data
152 expect(videos).to.have.lengthOf(2)
153 expect(videos[ 0 ].name).to.equal('normal')
154 expect(videos[ 1 ].name).to.equal('nsfw')
155 }
156 })
157
158 it('Should not display NSFW videos with do_not_list user NSFW policy', async function () {
159 await updateMyUser({
160 url: server.url,
161 accessToken: server.accessToken,
162 nsfwPolicy: 'do_not_list'
163 })
164
165 const results = [
166 await getVideosListWithToken(server.url, server.accessToken),
167 await searchVideoWithToken(server.url, 'n', server.accessToken)
168 ]
169 for (const res of results) {
170 expect(res.body.total).to.equal(1)
171
172 const videos = res.body.data
173 expect(videos).to.have.lengthOf(1)
174 expect(videos[ 0 ].name).to.equal('normal')
175 }
176 })
177
178 it('Should be able to see my NSFW videos even with do_not_list user NSFW policy', async function () {
179 const res = await getMyVideos(server.url, server.accessToken, 0, 5)
180 expect(res.body.total).to.equal(2)
181
182 const videos = res.body.data
183 expect(videos).to.have.lengthOf(2)
184 expect(videos[ 0 ].name).to.equal('normal')
185 expect(videos[ 1 ].name).to.equal('nsfw')
186 })
187 })
188
189 after(async function () {
190 killallServers([ server ])
191
192 // Keep the logs if the test failed
193 if (this['ok']) {
194 await flushTests()
195 }
196 })
197})
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts
index daf731a14..fc6b26c50 100644
--- a/server/tests/utils/users/users.ts
+++ b/server/tests/utils/users/users.ts
@@ -3,6 +3,7 @@ import * as request from 'supertest'
3import { makePostBodyRequest, makeUploadRequest, makePutBodyRequest } from '../' 3import { makePostBodyRequest, makeUploadRequest, makePutBodyRequest } from '../'
4 4
5import { UserRole } from '../../../../shared/index' 5import { UserRole } from '../../../../shared/index'
6import { NSFWPolicyType } from '../../../../shared/models/videos/nsfw-policy.type'
6 7
7function createUser ( 8function createUser (
8 url: string, 9 url: string,
@@ -128,7 +129,7 @@ function updateMyUser (options: {
128 url: string 129 url: string
129 accessToken: string, 130 accessToken: string,
130 newPassword?: string, 131 newPassword?: string,
131 displayNSFW?: boolean, 132 nsfwPolicy?: NSFWPolicyType,
132 email?: string, 133 email?: string,
133 autoPlayVideo?: boolean 134 autoPlayVideo?: boolean
134 description?: string 135 description?: string
@@ -137,7 +138,7 @@ function updateMyUser (options: {
137 138
138 const toSend = {} 139 const toSend = {}
139 if (options.newPassword !== undefined && options.newPassword !== null) toSend['password'] = options.newPassword 140 if (options.newPassword !== undefined && options.newPassword !== null) toSend['password'] = options.newPassword
140 if (options.displayNSFW !== undefined && options.displayNSFW !== null) toSend['displayNSFW'] = options.displayNSFW 141 if (options.nsfwPolicy !== undefined && options.nsfwPolicy !== null) toSend['nsfwPolicy'] = options.nsfwPolicy
141 if (options.autoPlayVideo !== undefined && options.autoPlayVideo !== null) toSend['autoPlayVideo'] = options.autoPlayVideo 142 if (options.autoPlayVideo !== undefined && options.autoPlayVideo !== null) toSend['autoPlayVideo'] = options.autoPlayVideo
142 if (options.email !== undefined && options.email !== null) toSend['email'] = options.email 143 if (options.email !== undefined && options.email !== null) toSend['email'] = options.email
143 if (options.description !== undefined && options.description !== null) toSend['description'] = options.description 144 if (options.description !== undefined && options.description !== null) toSend['description'] = options.description
diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts
index 01e7fa5a1..df9071c29 100644
--- a/server/tests/utils/videos/videos.ts
+++ b/server/tests/utils/videos/videos.ts
@@ -128,6 +128,18 @@ function getVideosList (url: string) {
128 .expect('Content-Type', /json/) 128 .expect('Content-Type', /json/)
129} 129}
130 130
131function getVideosListWithToken (url: string, token: string) {
132 const path = '/api/v1/videos'
133
134 return request(url)
135 .get(path)
136 .set('Authorization', 'Bearer ' + token)
137 .query({ sort: 'name' })
138 .set('Accept', 'application/json')
139 .expect(200)
140 .expect('Content-Type', /json/)
141}
142
131function getLocalVideos (url: string) { 143function getLocalVideos (url: string) {
132 const path = '/api/v1/videos' 144 const path = '/api/v1/videos'
133 145
@@ -202,6 +214,18 @@ function searchVideo (url: string, search: string) {
202 .expect('Content-Type', /json/) 214 .expect('Content-Type', /json/)
203} 215}
204 216
217function searchVideoWithToken (url: string, search: string, token: string) {
218 const path = '/api/v1/videos'
219 const req = request(url)
220 .get(path + '/search')
221 .set('Authorization', 'Bearer ' + token)
222 .query({ search })
223 .set('Accept', 'application/json')
224
225 return req.expect(200)
226 .expect('Content-Type', /json/)
227}
228
205function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) { 229function searchVideoWithPagination (url: string, search: string, start: number, count: number, sort?: string) {
206 const path = '/api/v1/videos' 230 const path = '/api/v1/videos'
207 231
@@ -490,6 +514,7 @@ export {
490 getVideoPrivacies, 514 getVideoPrivacies,
491 getVideoLanguages, 515 getVideoLanguages,
492 getMyVideos, 516 getMyVideos,
517 searchVideoWithToken,
493 getVideo, 518 getVideo,
494 getVideoWithToken, 519 getVideoWithToken,
495 getVideosList, 520 getVideosList,
@@ -499,6 +524,7 @@ export {
499 searchVideo, 524 searchVideo,
500 searchVideoWithPagination, 525 searchVideoWithPagination,
501 searchVideoWithSort, 526 searchVideoWithSort,
527 getVideosListWithToken,
502 uploadVideo, 528 uploadVideo,
503 updateVideo, 529 updateVideo,
504 rateVideo, 530 rateVideo,
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index b4d24cfbe..30956bd47 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -1,3 +1,5 @@
1import { NSFWPolicyType } from '../videos/nsfw-policy.type'
2
1export interface CustomConfig { 3export interface CustomConfig {
2 instance: { 4 instance: {
3 name: string 5 name: string
@@ -5,6 +7,7 @@ export interface CustomConfig {
5 description: string 7 description: string
6 terms: string 8 terms: string
7 defaultClientRoute: string 9 defaultClientRoute: string
10 defaultNSFWPolicy: NSFWPolicyType
8 customizations: { 11 customizations: {
9 javascript?: string 12 javascript?: string
10 css?: string 13 css?: string
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 611d0efe5..d1f956163 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -1,3 +1,5 @@
1import { NSFWPolicyType } from '../videos/nsfw-policy.type'
2
1export interface ServerConfig { 3export interface ServerConfig {
2 serverVersion: string 4 serverVersion: string
3 5
@@ -5,6 +7,7 @@ export interface ServerConfig {
5 name: string 7 name: string
6 shortDescription: string 8 shortDescription: string
7 defaultClientRoute: string 9 defaultClientRoute: string
10 defaultNSFWPolicy: NSFWPolicyType
8 customizations: { 11 customizations: {
9 javascript: string 12 javascript: string
10 css: string 13 css: string
diff --git a/shared/models/users/user-update-me.model.ts b/shared/models/users/user-update-me.model.ts
index b84233329..0a73879ed 100644
--- a/shared/models/users/user-update-me.model.ts
+++ b/shared/models/users/user-update-me.model.ts
@@ -1,6 +1,8 @@
1import { NSFWPolicyType } from '../videos/nsfw-policy.type'
2
1export interface UserUpdateMe { 3export interface UserUpdateMe {
2 description?: string 4 description?: string
3 displayNSFW?: boolean 5 nsfwPolicy?: NSFWPolicyType
4 autoPlayVideo?: boolean 6 autoPlayVideo?: boolean
5 email?: string 7 email?: string
6 password?: string 8 password?: string
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index b5f459f31..188e29ede 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -1,12 +1,13 @@
1import { Account } from '../actors' 1import { Account } from '../actors'
2import { VideoChannel } from '../videos/video-channel.model' 2import { VideoChannel } from '../videos/video-channel.model'
3import { UserRole } from './user-role' 3import { UserRole } from './user-role'
4import { NSFWPolicyType } from '../videos/nsfw-policy.type'
4 5
5export interface User { 6export interface User {
6 id: number 7 id: number
7 username: string 8 username: string
8 email: string 9 email: string
9 displayNSFW: boolean 10 nsfwPolicy: NSFWPolicyType
10 autoPlayVideo: boolean 11 autoPlayVideo: boolean
11 role: UserRole 12 role: UserRole
12 videoQuota: number 13 videoQuota: number
diff --git a/shared/models/videos/nsfw-policy.type.ts b/shared/models/videos/nsfw-policy.type.ts
new file mode 100644
index 000000000..dc0032a14
--- /dev/null
+++ b/shared/models/videos/nsfw-policy.type.ts
@@ -0,0 +1 @@
export type NSFWPolicyType = 'do_not_list' | 'blur' | 'display'