diff options
41 files changed, 498 insertions, 177 deletions
diff --git a/.gitignore b/.gitignore index 62e252782..5d882360d 100644 --- a/.gitignore +++ b/.gitignore | |||
@@ -7,6 +7,7 @@ | |||
7 | /test6/ | 7 | /test6/ |
8 | /uploads/ | 8 | /uploads/ |
9 | /videos/ | 9 | /videos/ |
10 | /avatars/ | ||
10 | /thumbnails/ | 11 | /thumbnails/ |
11 | /previews/ | 12 | /previews/ |
12 | /certs/ | 13 | /certs/ |
diff --git a/client/src/app/account/account-settings/account-settings.component.html b/client/src/app/account/account-settings/account-settings.component.html index f14eadd49..fe345207a 100644 --- a/client/src/app/account/account-settings/account-settings.component.html +++ b/client/src/app/account/account-settings/account-settings.component.html | |||
@@ -1,5 +1,5 @@ | |||
1 | <div class="user"> | 1 | <div class="user"> |
2 | <img [src]="getAvatarPath()" alt="Avatar" /> | 2 | <img [src]="getAvatarUrl()" alt="Avatar" /> |
3 | 3 | ||
4 | <div class="user-info"> | 4 | <div class="user-info"> |
5 | <div class="user-info-username">{{ user.username }}</div> | 5 | <div class="user-info-username">{{ user.username }}</div> |
@@ -7,6 +7,10 @@ | |||
7 | </div> | 7 | </div> |
8 | </div> | 8 | </div> |
9 | 9 | ||
10 | <div class="button-file"> | ||
11 | <span>Change your avatar</span> | ||
12 | <input #avatarfileInput type="file" name="avatarfile" id="avatarfile" (change)="changeAvatar()" /> | ||
13 | </div> | ||
10 | 14 | ||
11 | <div class="account-title">Account settings</div> | 15 | <div class="account-title">Account settings</div> |
12 | <my-account-change-password></my-account-change-password> | 16 | <my-account-change-password></my-account-change-password> |
diff --git a/client/src/app/account/account-settings/account-settings.component.scss b/client/src/app/account/account-settings/account-settings.component.scss index 7f1ade377..accd65214 100644 --- a/client/src/app/account/account-settings/account-settings.component.scss +++ b/client/src/app/account/account-settings/account-settings.component.scss | |||
@@ -21,6 +21,12 @@ | |||
21 | } | 21 | } |
22 | } | 22 | } |
23 | 23 | ||
24 | .button-file { | ||
25 | @include peertube-button-file(auto); | ||
26 | |||
27 | margin-top: 10px; | ||
28 | } | ||
29 | |||
24 | .account-title { | 30 | .account-title { |
25 | text-transform: uppercase; | 31 | text-transform: uppercase; |
26 | color: $orange-color; | 32 | color: $orange-color; |
diff --git a/client/src/app/account/account-settings/account-settings.component.ts b/client/src/app/account/account-settings/account-settings.component.ts index cba251000..3e03085ce 100644 --- a/client/src/app/account/account-settings/account-settings.component.ts +++ b/client/src/app/account/account-settings/account-settings.component.ts | |||
@@ -1,6 +1,10 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { HttpEventType, HttpResponse } from '@angular/common/http' |
2 | import { Component, OnInit, ViewChild } from '@angular/core' | ||
3 | import { NotificationsService } from 'angular2-notifications' | ||
4 | import { VideoPrivacy } from '../../../../../shared/models/videos' | ||
2 | import { User } from '../../shared' | 5 | import { User } from '../../shared' |
3 | import { AuthService } from '../../core' | 6 | import { AuthService } from '../../core' |
7 | import { UserService } from '../../shared/users' | ||
4 | 8 | ||
5 | @Component({ | 9 | @Component({ |
6 | selector: 'my-account-settings', | 10 | selector: 'my-account-settings', |
@@ -8,15 +12,39 @@ import { AuthService } from '../../core' | |||
8 | styleUrls: [ './account-settings.component.scss' ] | 12 | styleUrls: [ './account-settings.component.scss' ] |
9 | }) | 13 | }) |
10 | export class AccountSettingsComponent implements OnInit { | 14 | export class AccountSettingsComponent implements OnInit { |
15 | @ViewChild('avatarfileInput') avatarfileInput | ||
16 | |||
11 | user: User = null | 17 | user: User = null |
12 | 18 | ||
13 | constructor (private authService: AuthService) {} | 19 | constructor ( |
20 | private userService: UserService, | ||
21 | private authService: AuthService, | ||
22 | private notificationsService: NotificationsService | ||
23 | ) {} | ||
14 | 24 | ||
15 | ngOnInit () { | 25 | ngOnInit () { |
16 | this.user = this.authService.getUser() | 26 | this.user = this.authService.getUser() |
17 | } | 27 | } |
18 | 28 | ||
19 | getAvatarPath () { | 29 | getAvatarUrl () { |
20 | return this.user.getAvatarPath() | 30 | return this.user.getAvatarUrl() |
31 | } | ||
32 | |||
33 | changeAvatar () { | ||
34 | const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ] | ||
35 | |||
36 | const formData = new FormData() | ||
37 | formData.append('avatarfile', avatarfile) | ||
38 | |||
39 | this.userService.changeAvatar(formData) | ||
40 | .subscribe( | ||
41 | data => { | ||
42 | this.notificationsService.success('Success', 'Avatar changed.') | ||
43 | |||
44 | this.user.account.avatar = data.avatar | ||
45 | }, | ||
46 | |||
47 | err => this.notificationsService.error('Error', err.message) | ||
48 | ) | ||
21 | } | 49 | } |
22 | } | 50 | } |
diff --git a/client/src/app/account/account-videos/account-videos.component.ts b/client/src/app/account/account-videos/account-videos.component.ts index 22941619d..d51b70e06 100644 --- a/client/src/app/account/account-videos/account-videos.component.ts +++ b/client/src/app/account/account-videos/account-videos.component.ts | |||
@@ -68,7 +68,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit | |||
68 | .subscribe( | 68 | .subscribe( |
69 | res => this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`), | 69 | res => this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`), |
70 | 70 | ||
71 | err => this.notificationsService.error('Error', err.text) | 71 | err => this.notificationsService.error('Error', err.message) |
72 | ) | 72 | ) |
73 | } | 73 | } |
74 | ) | 74 | ) |
@@ -86,7 +86,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit | |||
86 | this.spliceVideosById(video.id) | 86 | this.spliceVideosById(video.id) |
87 | }, | 87 | }, |
88 | 88 | ||
89 | error => this.notificationsService.error('Error', error.text) | 89 | error => this.notificationsService.error('Error', error.message) |
90 | ) | 90 | ) |
91 | } | 91 | } |
92 | ) | 92 | ) |
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index c914848ae..8a2ba77d6 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts | |||
@@ -9,8 +9,8 @@ import 'rxjs/add/operator/mergeMap' | |||
9 | import { Observable } from 'rxjs/Observable' | 9 | import { Observable } from 'rxjs/Observable' |
10 | import { ReplaySubject } from 'rxjs/ReplaySubject' | 10 | import { ReplaySubject } from 'rxjs/ReplaySubject' |
11 | import { Subject } from 'rxjs/Subject' | 11 | import { Subject } from 'rxjs/Subject' |
12 | import { OAuthClientLocal, User as UserServerModel, UserRefreshToken, UserRole, VideoChannel } from '../../../../../shared' | 12 | import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared' |
13 | import { Account } from '../../../../../shared/models/actors' | 13 | import { User } from '../../../../../shared/models/users' |
14 | import { UserLogin } from '../../../../../shared/models/users/user-login.model' | 14 | import { UserLogin } from '../../../../../shared/models/users/user-login.model' |
15 | import { environment } from '../../../environments/environment' | 15 | import { environment } from '../../../environments/environment' |
16 | import { RestExtractor } from '../../shared/rest' | 16 | import { RestExtractor } from '../../shared/rest' |
@@ -25,20 +25,7 @@ interface UserLoginWithUsername extends UserLogin { | |||
25 | username: string | 25 | username: string |
26 | } | 26 | } |
27 | 27 | ||
28 | interface UserLoginWithUserInformation extends UserLogin { | 28 | type UserLoginWithUserInformation = UserLoginWithUsername & User |
29 | access_token: string | ||
30 | refresh_token: string | ||
31 | token_type: string | ||
32 | username: string | ||
33 | id: number | ||
34 | role: UserRole | ||
35 | displayNSFW: boolean | ||
36 | autoPlayVideo: boolean | ||
37 | email: string | ||
38 | videoQuota: number | ||
39 | account: Account | ||
40 | videoChannels: VideoChannel[] | ||
41 | } | ||
42 | 29 | ||
43 | @Injectable() | 30 | @Injectable() |
44 | export class AuthService { | 31 | export class AuthService { |
@@ -209,21 +196,7 @@ export class AuthService { | |||
209 | const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) | 196 | const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) |
210 | 197 | ||
211 | return this.http.get<UserServerModel>(AuthService.BASE_USER_INFORMATION_URL, { headers }) | 198 | return this.http.get<UserServerModel>(AuthService.BASE_USER_INFORMATION_URL, { headers }) |
212 | .map(res => { | 199 | .map(res => Object.assign(obj, res)) |
213 | const newProperties = { | ||
214 | id: res.id, | ||
215 | role: res.role, | ||
216 | displayNSFW: res.displayNSFW, | ||
217 | autoPlayVideo: res.autoPlayVideo, | ||
218 | email: res.email, | ||
219 | videoQuota: res.videoQuota, | ||
220 | account: res.account, | ||
221 | videoChannels: res.videoChannels | ||
222 | } | ||
223 | |||
224 | return Object.assign(obj, newProperties) | ||
225 | } | ||
226 | ) | ||
227 | } | 200 | } |
228 | 201 | ||
229 | private handleLogin (obj: UserLoginWithUserInformation) { | 202 | private handleLogin (obj: UserLoginWithUserInformation) { |
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index 6f52f4f45..5ea859fd2 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <menu> | 1 | <menu> |
2 | <div *ngIf="isLoggedIn" class="logged-in-block"> | 2 | <div *ngIf="isLoggedIn" class="logged-in-block"> |
3 | <img [src]="getUserAvatarPath()" alt="Avatar" /> | 3 | <img [src]="getUserAvatarUrl()" alt="Avatar" /> |
4 | 4 | ||
5 | <div class="logged-in-info"> | 5 | <div class="logged-in-info"> |
6 | <a routerLink="/account/settings" class="logged-in-username">{{ user.username }}</a> | 6 | <a routerLink="/account/settings" class="logged-in-username">{{ user.username }}</a> |
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts index 8b8b714a8..1f66e3754 100644 --- a/client/src/app/menu/menu.component.ts +++ b/client/src/app/menu/menu.component.ts | |||
@@ -51,8 +51,8 @@ export class MenuComponent implements OnInit { | |||
51 | ) | 51 | ) |
52 | } | 52 | } |
53 | 53 | ||
54 | getUserAvatarPath () { | 54 | getUserAvatarUrl () { |
55 | return this.user.getAvatarPath() | 55 | return this.user.getAvatarUrl() |
56 | } | 56 | } |
57 | 57 | ||
58 | isRegistrationAllowed () { | 58 | isRegistrationAllowed () { |
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts index bacaa208a..cc46dad77 100644 --- a/client/src/app/shared/account/account.model.ts +++ b/client/src/app/shared/account/account.model.ts | |||
@@ -1,11 +1,13 @@ | |||
1 | import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' | 1 | import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' |
2 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 2 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
3 | import { environment } from '../../../environments/environment' | 3 | import { environment } from '../../../environments/environment' |
4 | import { getAbsoluteAPIUrl } from '../misc/utils' | ||
4 | 5 | ||
5 | export class Account implements ServerAccount { | 6 | export class Account implements ServerAccount { |
6 | id: number | 7 | id: number |
7 | uuid: string | 8 | uuid: string |
8 | name: string | 9 | name: string |
10 | displayName: string | ||
9 | host: string | 11 | host: string |
10 | followingCount: number | 12 | followingCount: number |
11 | followersCount: number | 13 | followersCount: number |
@@ -13,9 +15,11 @@ export class Account implements ServerAccount { | |||
13 | updatedAt: Date | 15 | updatedAt: Date |
14 | avatar: Avatar | 16 | avatar: Avatar |
15 | 17 | ||
16 | static GET_ACCOUNT_AVATAR_PATH (account: Account) { | 18 | static GET_ACCOUNT_AVATAR_URL (account: Account) { |
17 | if (account && account.avatar) return account.avatar.path | 19 | const absoluteAPIUrl = getAbsoluteAPIUrl() |
18 | 20 | ||
19 | return '/client/assets/images/default-avatar.png' | 21 | if (account && account.avatar) return absoluteAPIUrl + account.avatar.path |
22 | |||
23 | return window.location.origin + '/client/assets/images/default-avatar.png' | ||
20 | } | 24 | } |
21 | } | 25 | } |
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 5525e4efb..2739ff81a 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript | 1 | // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript |
2 | 2 | ||
3 | import { environment } from '../../../environments/environment' | ||
3 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
4 | 5 | ||
5 | function getParameterByName (name: string, url: string) { | 6 | function getParameterByName (name: string, url: string) { |
@@ -38,8 +39,19 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: any[ | |||
38 | }) | 39 | }) |
39 | } | 40 | } |
40 | 41 | ||
42 | function getAbsoluteAPIUrl () { | ||
43 | let absoluteAPIUrl = environment.apiUrl | ||
44 | if (!absoluteAPIUrl) { | ||
45 | // The API is on the same domain | ||
46 | absoluteAPIUrl = window.location.origin | ||
47 | } | ||
48 | |||
49 | return absoluteAPIUrl | ||
50 | } | ||
51 | |||
41 | export { | 52 | export { |
42 | viewportHeight, | 53 | viewportHeight, |
43 | getParameterByName, | 54 | getParameterByName, |
44 | populateAsyncUserVideoChannels | 55 | populateAsyncUserVideoChannels, |
56 | getAbsoluteAPIUrl | ||
45 | } | 57 | } |
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index 7a962ae3e..83aae4463 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -57,7 +57,7 @@ export class User implements UserServerModel { | |||
57 | return hasUserRight(this.role, right) | 57 | return hasUserRight(this.role, right) |
58 | } | 58 | } |
59 | 59 | ||
60 | getAvatarPath () { | 60 | getAvatarUrl () { |
61 | return Account.GET_ACCOUNT_AVATAR_PATH(this.account) | 61 | return Account.GET_ACCOUNT_AVATAR_URL(this.account) |
62 | } | 62 | } |
63 | } | 63 | } |
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index d97edbcbe..58ddaa5ee 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts | |||
@@ -5,6 +5,7 @@ import 'rxjs/add/operator/map' | |||
5 | import { UserCreate, UserUpdateMe } from '../../../../../shared' | 5 | import { UserCreate, UserUpdateMe } from '../../../../../shared' |
6 | import { environment } from '../../../environments/environment' | 6 | import { environment } from '../../../environments/environment' |
7 | import { RestExtractor } from '../rest' | 7 | import { RestExtractor } from '../rest' |
8 | import { User } from './user.model' | ||
8 | 9 | ||
9 | @Injectable() | 10 | @Injectable() |
10 | export class UserService { | 11 | export class UserService { |
@@ -34,9 +35,24 @@ export class UserService { | |||
34 | .catch(res => this.restExtractor.handleError(res)) | 35 | .catch(res => this.restExtractor.handleError(res)) |
35 | } | 36 | } |
36 | 37 | ||
38 | changeAvatar (avatarForm: FormData) { | ||
39 | const url = UserService.BASE_USERS_URL + 'me/avatar/pick' | ||
40 | |||
41 | return this.authHttp.post(url, avatarForm) | ||
42 | .catch(this.restExtractor.handleError) | ||
43 | } | ||
44 | |||
37 | signup (userCreate: UserCreate) { | 45 | signup (userCreate: UserCreate) { |
38 | return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) | 46 | return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) |
39 | .map(this.restExtractor.extractDataBool) | 47 | .map(this.restExtractor.extractDataBool) |
40 | .catch(res => this.restExtractor.handleError(res)) | 48 | .catch(res => this.restExtractor.handleError(res)) |
41 | } | 49 | } |
50 | |||
51 | getMyInformation () { | ||
52 | const url = UserService.BASE_USERS_URL + 'me' | ||
53 | |||
54 | return this.authHttp.get(url) | ||
55 | .map((userHash: any) => new User(userHash)) | ||
56 | .catch(res => this.restExtractor.handleError(res)) | ||
57 | } | ||
42 | } | 58 | } |
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index bfe46bcdd..354373776 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -83,7 +83,7 @@ export abstract class AbstractVideoList implements OnInit { | |||
83 | this.videos = this.videos.concat(videos) | 83 | this.videos = this.videos.concat(videos) |
84 | } | 84 | } |
85 | }, | 85 | }, |
86 | error => this.notificationsService.error('Error', error.text) | 86 | error => this.notificationsService.error('Error', error.message) |
87 | ) | 87 | ) |
88 | } | 88 | } |
89 | 89 | ||
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index f159464c5..060bf933f 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -2,6 +2,7 @@ import { User } from '../' | |||
2 | import { Video as VideoServerModel } from '../../../../../shared' | 2 | import { Video as VideoServerModel } from '../../../../../shared' |
3 | import { Account } from '../../../../../shared/models/actors' | 3 | import { Account } from '../../../../../shared/models/actors' |
4 | import { environment } from '../../../environments/environment' | 4 | import { environment } from '../../../environments/environment' |
5 | import { getAbsoluteAPIUrl } from '../misc/utils' | ||
5 | 6 | ||
6 | export class Video implements VideoServerModel { | 7 | export class Video implements VideoServerModel { |
7 | accountName: string | 8 | accountName: string |
@@ -48,11 +49,7 @@ export class Video implements VideoServerModel { | |||
48 | } | 49 | } |
49 | 50 | ||
50 | constructor (hash: VideoServerModel) { | 51 | constructor (hash: VideoServerModel) { |
51 | let absoluteAPIUrl = environment.apiUrl | 52 | const absoluteAPIUrl = getAbsoluteAPIUrl() |
52 | if (!absoluteAPIUrl) { | ||
53 | // The API is on the same domain | ||
54 | absoluteAPIUrl = window.location.origin | ||
55 | } | ||
56 | 53 | ||
57 | this.accountName = hash.accountName | 54 | this.accountName = hash.accountName |
58 | this.createdAt = new Date(hash.createdAt.toString()) | 55 | this.createdAt = new Date(hash.createdAt.toString()) |
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index 81e3a0d19..0fefcee28 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss | |||
@@ -144,8 +144,3 @@ | |||
144 | } | 144 | } |
145 | } | 145 | } |
146 | } | 146 | } |
147 | |||
148 | .little-information { | ||
149 | font-size: 0.8em; | ||
150 | font-style: italic; | ||
151 | } | ||
diff --git a/client/src/app/videos/+video-edit/video-add.component.scss b/client/src/app/videos/+video-edit/video-add.component.scss index 891f38819..4bb509009 100644 --- a/client/src/app/videos/+video-edit/video-add.component.scss +++ b/client/src/app/videos/+video-edit/video-add.component.scss | |||
@@ -34,30 +34,9 @@ | |||
34 | } | 34 | } |
35 | 35 | ||
36 | .button-file { | 36 | .button-file { |
37 | position: relative; | 37 | @include peertube-button-file(190px); |
38 | overflow: hidden; | ||
39 | display: inline-block; | ||
40 | margin-bottom: 45px; | ||
41 | width: 190px; | ||
42 | |||
43 | @include peertube-button; | ||
44 | @include orange-button; | ||
45 | 38 | ||
46 | input[type=file] { | 39 | margin-bottom: 45px; |
47 | position: absolute; | ||
48 | top: 0; | ||
49 | right: 0; | ||
50 | min-width: 100%; | ||
51 | min-height: 100%; | ||
52 | font-size: 100px; | ||
53 | text-align: right; | ||
54 | filter: alpha(opacity=0); | ||
55 | opacity: 0; | ||
56 | outline: none; | ||
57 | background: white; | ||
58 | cursor: inherit; | ||
59 | display: block; | ||
60 | } | ||
61 | } | 40 | } |
62 | } | 41 | } |
63 | } | 42 | } |
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 4afd6160c..0f44d3dd7 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -148,7 +148,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
148 | this.router.navigate(['/videos/list']) | 148 | this.router.navigate(['/videos/list']) |
149 | }, | 149 | }, |
150 | 150 | ||
151 | error => this.notificationsService.error('Error', error.text) | 151 | error => this.notificationsService.error('Error', error.message) |
152 | ) | 152 | ) |
153 | } | 153 | } |
154 | ) | 154 | ) |
@@ -185,7 +185,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
185 | 185 | ||
186 | error => { | 186 | error => { |
187 | this.descriptionLoading = false | 187 | this.descriptionLoading = false |
188 | this.notificationsService.error('Error', error.text) | 188 | this.notificationsService.error('Error', error.message) |
189 | } | 189 | } |
190 | ) | 190 | ) |
191 | } | 191 | } |
@@ -217,7 +217,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
217 | } | 217 | } |
218 | 218 | ||
219 | getAvatarPath () { | 219 | getAvatarPath () { |
220 | return Account.GET_ACCOUNT_AVATAR_PATH(this.video.account) | 220 | return Account.GET_ACCOUNT_AVATAR_URL(this.video.account) |
221 | } | 221 | } |
222 | 222 | ||
223 | getVideoTags () { | 223 | getVideoTags () { |
@@ -247,7 +247,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
247 | this.router.navigate([ '/videos/list' ]) | 247 | this.router.navigate([ '/videos/list' ]) |
248 | }, | 248 | }, |
249 | 249 | ||
250 | error => this.notificationsService.error('Error', error.text) | 250 | error => this.notificationsService.error('Error', error.message) |
251 | ) | 251 | ) |
252 | } | 252 | } |
253 | ) | 253 | ) |
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 252cf2173..140de1b2c 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss | |||
@@ -84,6 +84,32 @@ | |||
84 | @include peertube-button; | 84 | @include peertube-button; |
85 | } | 85 | } |
86 | 86 | ||
87 | @mixin peertube-button-file ($width) { | ||
88 | position: relative; | ||
89 | overflow: hidden; | ||
90 | display: inline-block; | ||
91 | width: $width; | ||
92 | |||
93 | @include peertube-button; | ||
94 | @include orange-button; | ||
95 | |||
96 | input[type=file] { | ||
97 | position: absolute; | ||
98 | top: 0; | ||
99 | right: 0; | ||
100 | min-width: 100%; | ||
101 | min-height: 100%; | ||
102 | font-size: 100px; | ||
103 | text-align: right; | ||
104 | filter: alpha(opacity=0); | ||
105 | opacity: 0; | ||
106 | outline: none; | ||
107 | background: white; | ||
108 | cursor: inherit; | ||
109 | display: block; | ||
110 | } | ||
111 | } | ||
112 | |||
87 | @mixin avatar ($size) { | 113 | @mixin avatar ($size) { |
88 | width: $size; | 114 | width: $size; |
89 | height: $size; | 115 | height: $size; |
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 71e706346..e0ab3188b 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts | |||
@@ -16,17 +16,17 @@ import { VideoShareModel } from '../../models/video/video-share' | |||
16 | 16 | ||
17 | const activityPubClientRouter = express.Router() | 17 | const activityPubClientRouter = express.Router() |
18 | 18 | ||
19 | activityPubClientRouter.get('/account/:name', | 19 | activityPubClientRouter.get('/accounts/:name', |
20 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), | 20 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), |
21 | executeIfActivityPub(accountController) | 21 | executeIfActivityPub(accountController) |
22 | ) | 22 | ) |
23 | 23 | ||
24 | activityPubClientRouter.get('/account/:name/followers', | 24 | activityPubClientRouter.get('/accounts/:name/followers', |
25 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), | 25 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), |
26 | executeIfActivityPub(asyncMiddleware(accountFollowersController)) | 26 | executeIfActivityPub(asyncMiddleware(accountFollowersController)) |
27 | ) | 27 | ) |
28 | 28 | ||
29 | activityPubClientRouter.get('/account/:name/following', | 29 | activityPubClientRouter.get('/accounts/:name/following', |
30 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), | 30 | executeIfActivityPub(asyncMiddleware(localAccountValidator)), |
31 | executeIfActivityPub(asyncMiddleware(accountFollowingController)) | 31 | executeIfActivityPub(asyncMiddleware(accountFollowingController)) |
32 | ) | 32 | ) |
diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 75393ad17..57b98b84a 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts | |||
@@ -1,20 +1,26 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { extname, join } from 'path' | ||
3 | import * as uuidv4 from 'uuid/v4' | ||
2 | import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../shared' | 4 | import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../shared' |
5 | import { renamePromise } from '../../helpers/core-utils' | ||
3 | import { retryTransactionWrapper } from '../../helpers/database-utils' | 6 | import { retryTransactionWrapper } from '../../helpers/database-utils' |
4 | import { logger } from '../../helpers/logger' | 7 | import { logger } from '../../helpers/logger' |
5 | import { getFormattedObjects } from '../../helpers/utils' | 8 | import { createReqFiles, getFormattedObjects } from '../../helpers/utils' |
6 | import { CONFIG } from '../../initializers' | 9 | import { AVATAR_MIMETYPE_EXT, CONFIG, sequelizeTypescript } from '../../initializers' |
7 | import { createUserAccountAndChannel } from '../../lib/user' | 10 | import { createUserAccountAndChannel } from '../../lib/user' |
8 | import { | 11 | import { |
9 | asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setPagination, setUsersSort, | 12 | asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setPagination, setUsersSort, |
10 | setVideosSort, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, | 13 | setVideosSort, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, |
11 | usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator | 14 | usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator |
12 | } from '../../middlewares' | 15 | } from '../../middlewares' |
13 | import { videosSortValidator } from '../../middlewares/validators' | 16 | import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators' |
14 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' | 17 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' |
15 | import { UserModel } from '../../models/account/user' | 18 | import { UserModel } from '../../models/account/user' |
19 | import { AvatarModel } from '../../models/avatar/avatar' | ||
16 | import { VideoModel } from '../../models/video/video' | 20 | import { VideoModel } from '../../models/video/video' |
17 | 21 | ||
22 | const reqAvatarFile = createReqFiles('avatarfile', CONFIG.STORAGE.AVATARS_DIR, AVATAR_MIMETYPE_EXT) | ||
23 | |||
18 | const usersRouter = express.Router() | 24 | const usersRouter = express.Router() |
19 | 25 | ||
20 | usersRouter.get('/me', | 26 | usersRouter.get('/me', |
@@ -71,6 +77,13 @@ usersRouter.put('/me', | |||
71 | asyncMiddleware(updateMe) | 77 | asyncMiddleware(updateMe) |
72 | ) | 78 | ) |
73 | 79 | ||
80 | usersRouter.post('/me/avatar/pick', | ||
81 | authenticate, | ||
82 | reqAvatarFile, | ||
83 | usersUpdateMyAvatarValidator, | ||
84 | asyncMiddleware(updateMyAvatar) | ||
85 | ) | ||
86 | |||
74 | usersRouter.put('/:id', | 87 | usersRouter.put('/:id', |
75 | authenticate, | 88 | authenticate, |
76 | ensureUserHasRight(UserRight.MANAGE_USERS), | 89 | ensureUserHasRight(UserRight.MANAGE_USERS), |
@@ -216,6 +229,40 @@ async function updateMe (req: express.Request, res: express.Response, next: expr | |||
216 | return res.sendStatus(204) | 229 | return res.sendStatus(204) |
217 | } | 230 | } |
218 | 231 | ||
232 | async function updateMyAvatar (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
233 | const avatarPhysicalFile = req.files['avatarfile'][0] | ||
234 | const actor = res.locals.oauth.token.user.Account.Actor | ||
235 | |||
236 | const avatarDir = CONFIG.STORAGE.AVATARS_DIR | ||
237 | const source = join(avatarDir, avatarPhysicalFile.filename) | ||
238 | const extension = extname(avatarPhysicalFile.filename) | ||
239 | const avatarName = uuidv4() + extension | ||
240 | const destination = join(avatarDir, avatarName) | ||
241 | |||
242 | await renamePromise(source, destination) | ||
243 | |||
244 | const { avatar } = await sequelizeTypescript.transaction(async t => { | ||
245 | const avatar = await AvatarModel.create({ | ||
246 | filename: avatarName | ||
247 | }, { transaction: t }) | ||
248 | |||
249 | if (actor.Avatar) { | ||
250 | await actor.Avatar.destroy({ transaction: t }) | ||
251 | } | ||
252 | |||
253 | actor.set('avatarId', avatar.id) | ||
254 | await actor.save({ transaction: t }) | ||
255 | |||
256 | return { actor, avatar } | ||
257 | }) | ||
258 | |||
259 | return res | ||
260 | .json({ | ||
261 | avatar: avatar.toFormattedJSON() | ||
262 | }) | ||
263 | .end() | ||
264 | } | ||
265 | |||
219 | async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) { | 266 | async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) { |
220 | const body: UserUpdate = req.body | 267 | const body: UserUpdate = req.body |
221 | const user = res.locals.user as UserModel | 268 | const user = res.locals.user as UserModel |
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 11e3da5cc..ff0d967e1 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts | |||
@@ -6,7 +6,7 @@ import { renamePromise } from '../../../helpers/core-utils' | |||
6 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | 6 | import { retryTransactionWrapper } from '../../../helpers/database-utils' |
7 | import { getVideoFileHeight } from '../../../helpers/ffmpeg-utils' | 7 | import { getVideoFileHeight } from '../../../helpers/ffmpeg-utils' |
8 | import { logger } from '../../../helpers/logger' | 8 | import { logger } from '../../../helpers/logger' |
9 | import { generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils' | 9 | import { createReqFiles, generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils' |
10 | import { | 10 | import { |
11 | CONFIG, sequelizeTypescript, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, | 11 | CONFIG, sequelizeTypescript, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, |
12 | VIDEO_PRIVACIES | 12 | VIDEO_PRIVACIES |
@@ -29,28 +29,7 @@ import { rateVideoRouter } from './rate' | |||
29 | 29 | ||
30 | const videosRouter = express.Router() | 30 | const videosRouter = express.Router() |
31 | 31 | ||
32 | // multer configuration | 32 | const reqVideoFile = createReqFiles('videofile', CONFIG.STORAGE.VIDEOS_DIR, VIDEO_MIMETYPE_EXT) |
33 | const storage = multer.diskStorage({ | ||
34 | destination: (req, file, cb) => { | ||
35 | cb(null, CONFIG.STORAGE.VIDEOS_DIR) | ||
36 | }, | ||
37 | |||
38 | filename: async (req, file, cb) => { | ||
39 | const extension = VIDEO_MIMETYPE_EXT[file.mimetype] | ||
40 | let randomString = '' | ||
41 | |||
42 | try { | ||
43 | randomString = await generateRandomString(16) | ||
44 | } catch (err) { | ||
45 | logger.error('Cannot generate random string for file name.', err) | ||
46 | randomString = 'fake-random-string' | ||
47 | } | ||
48 | |||
49 | cb(null, randomString + extension) | ||
50 | } | ||
51 | }) | ||
52 | |||
53 | const reqFiles = multer({ storage: storage }).fields([{ name: 'videofile', maxCount: 1 }]) | ||
54 | 33 | ||
55 | videosRouter.use('/', abuseVideoRouter) | 34 | videosRouter.use('/', abuseVideoRouter) |
56 | videosRouter.use('/', blacklistRouter) | 35 | videosRouter.use('/', blacklistRouter) |
@@ -85,7 +64,7 @@ videosRouter.put('/:id', | |||
85 | ) | 64 | ) |
86 | videosRouter.post('/upload', | 65 | videosRouter.post('/upload', |
87 | authenticate, | 66 | authenticate, |
88 | reqFiles, | 67 | reqVideoFile, |
89 | asyncMiddleware(videosAddValidator), | 68 | asyncMiddleware(videosAddValidator), |
90 | asyncMiddleware(addVideoRetryWrapper) | 69 | asyncMiddleware(addVideoRetryWrapper) |
91 | ) | 70 | ) |
diff --git a/server/controllers/static.ts b/server/controllers/static.ts index ccae60517..eece9c06b 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts | |||
@@ -32,6 +32,12 @@ staticRouter.use( | |||
32 | express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE }) | 32 | express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE }) |
33 | ) | 33 | ) |
34 | 34 | ||
35 | const avatarsPhysicalPath = CONFIG.STORAGE.AVATARS_DIR | ||
36 | staticRouter.use( | ||
37 | STATIC_PATHS.AVATARS, | ||
38 | express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE }) | ||
39 | ) | ||
40 | |||
35 | // Video previews path for express | 41 | // Video previews path for express |
36 | staticRouter.use( | 42 | staticRouter.use( |
37 | STATIC_PATHS.PREVIEWS + ':uuid.jpg', | 43 | STATIC_PATHS.PREVIEWS + ':uuid.jpg', |
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts index 159c2a700..6ed60c1c4 100644 --- a/server/helpers/custom-validators/users.ts +++ b/server/helpers/custom-validators/users.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import * as validator from 'validator' | 1 | import * as validator from 'validator' |
2 | import 'express-validator' | 2 | import 'express-validator' |
3 | 3 | ||
4 | import { exists } from './misc' | 4 | import { exists, isArray } from './misc' |
5 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 5 | import { CONSTRAINTS_FIELDS } from '../../initializers' |
6 | import { UserRole } from '../../../shared' | 6 | import { UserRole } from '../../../shared' |
7 | 7 | ||
@@ -37,6 +37,22 @@ function isUserRoleValid (value: any) { | |||
37 | return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined | 37 | return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined |
38 | } | 38 | } |
39 | 39 | ||
40 | function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { | ||
41 | // Should have files | ||
42 | if (!files) return false | ||
43 | if (isArray(files)) return false | ||
44 | |||
45 | // Should have videofile file | ||
46 | const avatarfile = files['avatarfile'] | ||
47 | if (!avatarfile || avatarfile.length === 0) return false | ||
48 | |||
49 | // The file should exist | ||
50 | const file = avatarfile[0] | ||
51 | if (!file || !file.originalname) return false | ||
52 | |||
53 | return new RegExp('^image/(png|jpeg)$', 'i').test(file.mimetype) | ||
54 | } | ||
55 | |||
40 | // --------------------------------------------------------------------------- | 56 | // --------------------------------------------------------------------------- |
41 | 57 | ||
42 | export { | 58 | export { |
@@ -45,5 +61,6 @@ export { | |||
45 | isUserVideoQuotaValid, | 61 | isUserVideoQuotaValid, |
46 | isUserUsernameValid, | 62 | isUserUsernameValid, |
47 | isUserDisplayNSFWValid, | 63 | isUserDisplayNSFWValid, |
48 | isUserAutoPlayVideoValid | 64 | isUserAutoPlayVideoValid, |
65 | isAvatarFile | ||
49 | } | 66 | } |
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index 769aa83c6..7a32e286c 100644 --- a/server/helpers/utils.ts +++ b/server/helpers/utils.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import * as multer from 'multer' | ||
2 | import { Model } from 'sequelize-typescript' | 3 | import { Model } from 'sequelize-typescript' |
3 | import { ResultList } from '../../shared' | 4 | import { ResultList } from '../../shared' |
4 | import { VideoResolution } from '../../shared/models/videos' | 5 | import { VideoResolution } from '../../shared/models/videos' |
5 | import { CONFIG, REMOTE_SCHEME } from '../initializers' | 6 | import { CONFIG, REMOTE_SCHEME, VIDEO_MIMETYPE_EXT } from '../initializers' |
6 | import { UserModel } from '../models/account/user' | 7 | import { UserModel } from '../models/account/user' |
7 | import { ActorModel } from '../models/activitypub/actor' | 8 | import { ActorModel } from '../models/activitypub/actor' |
8 | import { ApplicationModel } from '../models/application/application' | 9 | import { ApplicationModel } from '../models/application/application' |
@@ -26,6 +27,30 @@ function badRequest (req: express.Request, res: express.Response, next: express. | |||
26 | return res.type('json').status(400).end() | 27 | return res.type('json').status(400).end() |
27 | } | 28 | } |
28 | 29 | ||
30 | function createReqFiles (fieldName: string, storageDir: string, mimeTypes: { [ id: string ]: string }) { | ||
31 | const storage = multer.diskStorage({ | ||
32 | destination: (req, file, cb) => { | ||
33 | cb(null, storageDir) | ||
34 | }, | ||
35 | |||
36 | filename: async (req, file, cb) => { | ||
37 | const extension = mimeTypes[file.mimetype] | ||
38 | let randomString = '' | ||
39 | |||
40 | try { | ||
41 | randomString = await generateRandomString(16) | ||
42 | } catch (err) { | ||
43 | logger.error('Cannot generate random string for file name.', err) | ||
44 | randomString = 'fake-random-string' | ||
45 | } | ||
46 | |||
47 | cb(null, randomString + extension) | ||
48 | } | ||
49 | }) | ||
50 | |||
51 | return multer({ storage }).fields([{ name: fieldName, maxCount: 1 }]) | ||
52 | } | ||
53 | |||
29 | async function generateRandomString (size: number) { | 54 | async function generateRandomString (size: number) { |
30 | const raw = await pseudoRandomBytesPromise(size) | 55 | const raw = await pseudoRandomBytesPromise(size) |
31 | 56 | ||
@@ -122,5 +147,6 @@ export { | |||
122 | resetSequelizeInstance, | 147 | resetSequelizeInstance, |
123 | getServerActor, | 148 | getServerActor, |
124 | SortType, | 149 | SortType, |
125 | getHostWithPort | 150 | getHostWithPort, |
151 | createReqFiles | ||
126 | } | 152 | } |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 3a5a557d4..50a29dc43 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -9,7 +9,7 @@ import { isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core | |||
9 | 9 | ||
10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
11 | 11 | ||
12 | const LAST_MIGRATION_VERSION = 145 | 12 | const LAST_MIGRATION_VERSION = 150 |
13 | 13 | ||
14 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
15 | 15 | ||
@@ -172,7 +172,10 @@ const CONSTRAINTS_FIELDS = { | |||
172 | ACTOR: { | 172 | ACTOR: { |
173 | PUBLIC_KEY: { min: 10, max: 5000 }, // Length | 173 | PUBLIC_KEY: { min: 10, max: 5000 }, // Length |
174 | PRIVATE_KEY: { min: 10, max: 5000 }, // Length | 174 | PRIVATE_KEY: { min: 10, max: 5000 }, // Length |
175 | URL: { min: 3, max: 2000 } // Length | 175 | URL: { min: 3, max: 2000 }, // Length |
176 | AVATAR: { | ||
177 | EXTNAME: [ '.png', '.jpeg', '.jpg' ] | ||
178 | } | ||
176 | }, | 179 | }, |
177 | VIDEO_EVENTS: { | 180 | VIDEO_EVENTS: { |
178 | COUNT: { min: 0 } | 181 | COUNT: { min: 0 } |
@@ -250,6 +253,12 @@ const VIDEO_MIMETYPE_EXT = { | |||
250 | 'video/mp4': '.mp4' | 253 | 'video/mp4': '.mp4' |
251 | } | 254 | } |
252 | 255 | ||
256 | const AVATAR_MIMETYPE_EXT = { | ||
257 | 'image/png': '.png', | ||
258 | 'image/jpg': '.jpg', | ||
259 | 'image/jpeg': '.jpg' | ||
260 | } | ||
261 | |||
253 | // --------------------------------------------------------------------------- | 262 | // --------------------------------------------------------------------------- |
254 | 263 | ||
255 | const SERVER_ACTOR_NAME = 'peertube' | 264 | const SERVER_ACTOR_NAME = 'peertube' |
@@ -291,7 +300,8 @@ const STATIC_PATHS = { | |||
291 | PREVIEWS: '/static/previews/', | 300 | PREVIEWS: '/static/previews/', |
292 | THUMBNAILS: '/static/thumbnails/', | 301 | THUMBNAILS: '/static/thumbnails/', |
293 | TORRENTS: '/static/torrents/', | 302 | TORRENTS: '/static/torrents/', |
294 | WEBSEED: '/static/webseed/' | 303 | WEBSEED: '/static/webseed/', |
304 | AVATARS: '/static/avatars/' | ||
295 | } | 305 | } |
296 | 306 | ||
297 | // Cache control | 307 | // Cache control |
@@ -376,5 +386,6 @@ export { | |||
376 | VIDEO_PRIVACIES, | 386 | VIDEO_PRIVACIES, |
377 | VIDEO_LICENCES, | 387 | VIDEO_LICENCES, |
378 | VIDEO_RATE_TYPES, | 388 | VIDEO_RATE_TYPES, |
379 | VIDEO_MIMETYPE_EXT | 389 | VIDEO_MIMETYPE_EXT, |
390 | AVATAR_MIMETYPE_EXT | ||
380 | } | 391 | } |
diff --git a/server/initializers/migrations/0150-avatar-cascade.ts b/server/initializers/migrations/0150-avatar-cascade.ts new file mode 100644 index 000000000..821696717 --- /dev/null +++ b/server/initializers/migrations/0150-avatar-cascade.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction, | ||
5 | queryInterface: Sequelize.QueryInterface, | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): Promise<void> { | ||
8 | await utils.queryInterface.removeConstraint('actor', 'actor_avatarId_fkey') | ||
9 | |||
10 | await utils.queryInterface.addConstraint('actor', [ 'avatarId' ], { | ||
11 | type: 'foreign key', | ||
12 | references: { | ||
13 | table: 'avatar', | ||
14 | field: 'id' | ||
15 | }, | ||
16 | onDelete: 'set null', | ||
17 | onUpdate: 'CASCADE' | ||
18 | }) | ||
19 | } | ||
20 | |||
21 | function down (options) { | ||
22 | throw new Error('Not implemented.') | ||
23 | } | ||
24 | |||
25 | export { | ||
26 | up, | ||
27 | down | ||
28 | } | ||
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index e590dc72d..e557896e8 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts | |||
@@ -1,16 +1,20 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { join } from 'path' | ||
2 | import { Transaction } from 'sequelize' | 3 | import { Transaction } from 'sequelize' |
3 | import * as url from 'url' | 4 | import * as url from 'url' |
5 | import * as uuidv4 from 'uuid/v4' | ||
4 | import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' | 6 | import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' |
5 | import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' | 7 | import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' |
6 | import { isRemoteActorValid } from '../../helpers/custom-validators/activitypub/actor' | 8 | import { isRemoteActorValid } from '../../helpers/custom-validators/activitypub/actor' |
9 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
7 | import { retryTransactionWrapper } from '../../helpers/database-utils' | 10 | import { retryTransactionWrapper } from '../../helpers/database-utils' |
8 | import { logger } from '../../helpers/logger' | 11 | import { logger } from '../../helpers/logger' |
9 | import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto' | 12 | import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto' |
10 | import { doRequest } from '../../helpers/requests' | 13 | import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' |
11 | import { CONFIG, sequelizeTypescript } from '../../initializers' | 14 | import { CONFIG, sequelizeTypescript } from '../../initializers' |
12 | import { AccountModel } from '../../models/account/account' | 15 | import { AccountModel } from '../../models/account/account' |
13 | import { ActorModel } from '../../models/activitypub/actor' | 16 | import { ActorModel } from '../../models/activitypub/actor' |
17 | import { AvatarModel } from '../../models/avatar/avatar' | ||
14 | import { ServerModel } from '../../models/server/server' | 18 | import { ServerModel } from '../../models/server/server' |
15 | import { VideoChannelModel } from '../../models/video/video-channel' | 19 | import { VideoChannelModel } from '../../models/video/video-channel' |
16 | 20 | ||
@@ -62,6 +66,32 @@ async function getOrCreateActorAndServerAndModel (actorUrl: string, recurseIfNee | |||
62 | return actor | 66 | return actor |
63 | } | 67 | } |
64 | 68 | ||
69 | function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) { | ||
70 | return new ActorModel({ | ||
71 | type, | ||
72 | url, | ||
73 | preferredUsername, | ||
74 | uuid, | ||
75 | publicKey: null, | ||
76 | privateKey: null, | ||
77 | followersCount: 0, | ||
78 | followingCount: 0, | ||
79 | inboxUrl: url + '/inbox', | ||
80 | outboxUrl: url + '/outbox', | ||
81 | sharedInboxUrl: CONFIG.WEBSERVER.URL + '/inbox', | ||
82 | followersUrl: url + '/followers', | ||
83 | followingUrl: url + '/following' | ||
84 | }) | ||
85 | } | ||
86 | |||
87 | export { | ||
88 | getOrCreateActorAndServerAndModel, | ||
89 | buildActorInstance, | ||
90 | setAsyncActorKeys | ||
91 | } | ||
92 | |||
93 | // --------------------------------------------------------------------------- | ||
94 | |||
65 | function saveActorAndServerAndModelIfNotExist ( | 95 | function saveActorAndServerAndModelIfNotExist ( |
66 | result: FetchRemoteActorResult, | 96 | result: FetchRemoteActorResult, |
67 | ownerActor?: ActorModel, | 97 | ownerActor?: ActorModel, |
@@ -90,6 +120,14 @@ function saveActorAndServerAndModelIfNotExist ( | |||
90 | // Save our new account in database | 120 | // Save our new account in database |
91 | actor.set('serverId', server.id) | 121 | actor.set('serverId', server.id) |
92 | 122 | ||
123 | // Avatar? | ||
124 | if (result.avatarName) { | ||
125 | const avatar = await AvatarModel.create({ | ||
126 | filename: result.avatarName | ||
127 | }, { transaction: t }) | ||
128 | actor.set('avatarId', avatar.id) | ||
129 | } | ||
130 | |||
93 | // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists | 131 | // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists |
94 | // (which could be false in a retried query) | 132 | // (which could be false in a retried query) |
95 | const actorCreated = await ActorModel.create(actor.toJSON(), { transaction: t }) | 133 | const actorCreated = await ActorModel.create(actor.toJSON(), { transaction: t }) |
@@ -112,6 +150,7 @@ type FetchRemoteActorResult = { | |||
112 | actor: ActorModel | 150 | actor: ActorModel |
113 | name: string | 151 | name: string |
114 | summary: string | 152 | summary: string |
153 | avatarName?: string | ||
115 | attributedTo: ActivityPubAttributedTo[] | 154 | attributedTo: ActivityPubAttributedTo[] |
116 | } | 155 | } |
117 | async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResult> { | 156 | async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResult> { |
@@ -151,43 +190,33 @@ async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResu | |||
151 | followingUrl: actorJSON.following | 190 | followingUrl: actorJSON.following |
152 | }) | 191 | }) |
153 | 192 | ||
193 | // Fetch icon? | ||
194 | let avatarName: string = undefined | ||
195 | if ( | ||
196 | actorJSON.icon && actorJSON.icon.type === 'Image' && actorJSON.icon.mediaType === 'image/png' && | ||
197 | isActivityPubUrlValid(actorJSON.icon.url) | ||
198 | ) { | ||
199 | const extension = actorJSON.icon.mediaType === 'image/png' ? '.png' : '.jpg' | ||
200 | |||
201 | avatarName = uuidv4() + extension | ||
202 | const destPath = join(CONFIG.STORAGE.AVATARS_DIR, avatarName) | ||
203 | |||
204 | await doRequestAndSaveToFile({ | ||
205 | method: 'GET', | ||
206 | uri: actorJSON.icon.url | ||
207 | }, destPath) | ||
208 | } | ||
209 | |||
154 | const name = actorJSON.name || actorJSON.preferredUsername | 210 | const name = actorJSON.name || actorJSON.preferredUsername |
155 | return { | 211 | return { |
156 | actor, | 212 | actor, |
157 | name, | 213 | name, |
214 | avatarName, | ||
158 | summary: actorJSON.summary, | 215 | summary: actorJSON.summary, |
159 | attributedTo: actorJSON.attributedTo | 216 | attributedTo: actorJSON.attributedTo |
160 | } | 217 | } |
161 | } | 218 | } |
162 | 219 | ||
163 | function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) { | ||
164 | return new ActorModel({ | ||
165 | type, | ||
166 | url, | ||
167 | preferredUsername, | ||
168 | uuid, | ||
169 | publicKey: null, | ||
170 | privateKey: null, | ||
171 | followersCount: 0, | ||
172 | followingCount: 0, | ||
173 | inboxUrl: url + '/inbox', | ||
174 | outboxUrl: url + '/outbox', | ||
175 | sharedInboxUrl: CONFIG.WEBSERVER.URL + '/inbox', | ||
176 | followersUrl: url + '/followers', | ||
177 | followingUrl: url + '/following' | ||
178 | }) | ||
179 | } | ||
180 | |||
181 | export { | ||
182 | getOrCreateActorAndServerAndModel, | ||
183 | saveActorAndServerAndModelIfNotExist, | ||
184 | fetchRemoteActor, | ||
185 | buildActorInstance, | ||
186 | setAsyncActorKeys | ||
187 | } | ||
188 | |||
189 | // --------------------------------------------------------------------------- | ||
190 | |||
191 | async function fetchActorTotalItems (url: string) { | 220 | async function fetchActorTotalItems (url: string) { |
192 | const options = { | 221 | const options = { |
193 | uri: url, | 222 | uri: url, |
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index 3d5f0523c..0d76922e0 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts | |||
@@ -18,7 +18,7 @@ function getVideoChannelActivityPubUrl (videoChannelUUID: string) { | |||
18 | } | 18 | } |
19 | 19 | ||
20 | function getAccountActivityPubUrl (accountName: string) { | 20 | function getAccountActivityPubUrl (accountName: string) { |
21 | return CONFIG.WEBSERVER.URL + '/account/' + accountName | 21 | return CONFIG.WEBSERVER.URL + '/accounts/' + accountName |
22 | } | 22 | } |
23 | 23 | ||
24 | function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) { | 24 | function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) { |
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index db40a5c88..42ebddd56 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts | |||
@@ -3,12 +3,14 @@ import 'express-validator' | |||
3 | import { body, param } from 'express-validator/check' | 3 | import { body, param } from 'express-validator/check' |
4 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | 4 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' |
5 | import { | 5 | import { |
6 | isAvatarFile, | ||
6 | isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, | 7 | isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, |
7 | isUserVideoQuotaValid | 8 | isUserVideoQuotaValid |
8 | } from '../../helpers/custom-validators/users' | 9 | } from '../../helpers/custom-validators/users' |
9 | import { isVideoExist } from '../../helpers/custom-validators/videos' | 10 | import { isVideoExist, isVideoFile } from '../../helpers/custom-validators/videos' |
10 | import { logger } from '../../helpers/logger' | 11 | import { logger } from '../../helpers/logger' |
11 | import { isSignupAllowed } from '../../helpers/utils' | 12 | import { isSignupAllowed } from '../../helpers/utils' |
13 | import { CONSTRAINTS_FIELDS } from '../../initializers' | ||
12 | import { UserModel } from '../../models/account/user' | 14 | import { UserModel } from '../../models/account/user' |
13 | import { areValidationErrors } from './utils' | 15 | import { areValidationErrors } from './utils' |
14 | 16 | ||
@@ -96,6 +98,21 @@ const usersUpdateMeValidator = [ | |||
96 | } | 98 | } |
97 | ] | 99 | ] |
98 | 100 | ||
101 | const usersUpdateMyAvatarValidator = [ | ||
102 | body('avatarfile').custom((value, { req }) => isAvatarFile(req.files)).withMessage( | ||
103 | 'This file is not supported. Please, make sure it is of the following type : ' | ||
104 | + CONSTRAINTS_FIELDS.ACTOR.AVATAR.EXTNAME.join(', ') | ||
105 | ), | ||
106 | |||
107 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
108 | logger.debug('Checking usersUpdateMyAvatarValidator parameters', { parameters: req.body }) | ||
109 | |||
110 | if (areValidationErrors(req, res)) return | ||
111 | |||
112 | return next() | ||
113 | } | ||
114 | ] | ||
115 | |||
99 | const usersGetValidator = [ | 116 | const usersGetValidator = [ |
100 | param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), | 117 | param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), |
101 | 118 | ||
@@ -145,7 +162,8 @@ export { | |||
145 | usersUpdateMeValidator, | 162 | usersUpdateMeValidator, |
146 | usersVideoRatingValidator, | 163 | usersVideoRatingValidator, |
147 | ensureUserRegistrationAllowed, | 164 | ensureUserRegistrationAllowed, |
148 | usersGetValidator | 165 | usersGetValidator, |
166 | usersUpdateMyAvatarValidator | ||
149 | } | 167 | } |
150 | 168 | ||
151 | // --------------------------------------------------------------------------- | 169 | // --------------------------------------------------------------------------- |
diff --git a/server/models/account/account.ts b/server/models/account/account.ts index 1ee232537..d3503aaa3 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts | |||
@@ -13,6 +13,7 @@ import { | |||
13 | Table, | 13 | Table, |
14 | UpdatedAt | 14 | UpdatedAt |
15 | } from 'sequelize-typescript' | 15 | } from 'sequelize-typescript' |
16 | import { Account } from '../../../shared/models/actors' | ||
16 | import { isUserUsernameValid } from '../../helpers/custom-validators/users' | 17 | import { isUserUsernameValid } from '../../helpers/custom-validators/users' |
17 | import { sendDeleteActor } from '../../lib/activitypub/send' | 18 | import { sendDeleteActor } from '../../lib/activitypub/send' |
18 | import { ActorModel } from '../activitypub/actor' | 19 | import { ActorModel } from '../activitypub/actor' |
@@ -165,11 +166,12 @@ export class AccountModel extends Model<AccountModel> { | |||
165 | return AccountModel.findOne(query) | 166 | return AccountModel.findOne(query) |
166 | } | 167 | } |
167 | 168 | ||
168 | toFormattedJSON () { | 169 | toFormattedJSON (): Account { |
169 | const actor = this.Actor.toFormattedJSON() | 170 | const actor = this.Actor.toFormattedJSON() |
170 | const account = { | 171 | const account = { |
171 | id: this.id, | 172 | id: this.id, |
172 | name: this.name, | 173 | name: this.Actor.preferredUsername, |
174 | displayName: this.name, | ||
173 | createdAt: this.createdAt, | 175 | createdAt: this.createdAt, |
174 | updatedAt: this.updatedAt | 176 | updatedAt: this.updatedAt |
175 | } | 177 | } |
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index d7e09e328..4226bcb35 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -4,6 +4,7 @@ import { | |||
4 | Scopes, Table, UpdatedAt | 4 | Scopes, Table, UpdatedAt |
5 | } from 'sequelize-typescript' | 5 | } from 'sequelize-typescript' |
6 | import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' | 6 | import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' |
7 | import { User } from '../../../shared/models/users' | ||
7 | import { | 8 | import { |
8 | isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, | 9 | isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, |
9 | isUserVideoQuotaValid | 10 | isUserVideoQuotaValid |
@@ -210,7 +211,7 @@ export class UserModel extends Model<UserModel> { | |||
210 | return comparePassword(password, this.password) | 211 | return comparePassword(password, this.password) |
211 | } | 212 | } |
212 | 213 | ||
213 | toFormattedJSON () { | 214 | toFormattedJSON (): User { |
214 | const json = { | 215 | const json = { |
215 | id: this.id, | 216 | id: this.id, |
216 | username: this.username, | 217 | username: this.username, |
@@ -221,11 +222,12 @@ export class UserModel extends Model<UserModel> { | |||
221 | roleLabel: USER_ROLE_LABELS[ this.role ], | 222 | roleLabel: USER_ROLE_LABELS[ this.role ], |
222 | videoQuota: this.videoQuota, | 223 | videoQuota: this.videoQuota, |
223 | createdAt: this.createdAt, | 224 | createdAt: this.createdAt, |
224 | account: this.Account.toFormattedJSON() | 225 | account: this.Account.toFormattedJSON(), |
226 | videoChannels: [] | ||
225 | } | 227 | } |
226 | 228 | ||
227 | if (Array.isArray(this.Account.VideoChannels) === true) { | 229 | if (Array.isArray(this.Account.VideoChannels) === true) { |
228 | json['videoChannels'] = this.Account.VideoChannels | 230 | json.videoChannels = this.Account.VideoChannels |
229 | .map(c => c.toFormattedJSON()) | 231 | .map(c => c.toFormattedJSON()) |
230 | .sort((v1, v2) => { | 232 | .sort((v1, v2) => { |
231 | if (v1.createdAt < v2.createdAt) return -1 | 233 | if (v1.createdAt < v2.createdAt) return -1 |
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 3d96b3706..8422653df 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { values } from 'lodash' | 1 | import { values } from 'lodash' |
2 | import { join } from 'path' | 2 | import { extname, join } from 'path' |
3 | import * as Sequelize from 'sequelize' | 3 | import * as Sequelize from 'sequelize' |
4 | import { | 4 | import { |
5 | AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, DefaultScope, ForeignKey, HasMany, HasOne, Is, IsUUID, Model, Scopes, | 5 | AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, DefaultScope, ForeignKey, HasMany, HasOne, Is, IsUUID, Model, Scopes, |
@@ -30,6 +30,10 @@ enum ScopeNames { | |||
30 | { | 30 | { |
31 | model: () => ServerModel, | 31 | model: () => ServerModel, |
32 | required: false | 32 | required: false |
33 | }, | ||
34 | { | ||
35 | model: () => AvatarModel, | ||
36 | required: false | ||
33 | } | 37 | } |
34 | ] | 38 | ] |
35 | }) | 39 | }) |
@@ -47,6 +51,10 @@ enum ScopeNames { | |||
47 | { | 51 | { |
48 | model: () => ServerModel, | 52 | model: () => ServerModel, |
49 | required: false | 53 | required: false |
54 | }, | ||
55 | { | ||
56 | model: () => AvatarModel, | ||
57 | required: false | ||
50 | } | 58 | } |
51 | ] | 59 | ] |
52 | } | 60 | } |
@@ -141,7 +149,7 @@ export class ActorModel extends Model<ActorModel> { | |||
141 | foreignKey: { | 149 | foreignKey: { |
142 | allowNull: true | 150 | allowNull: true |
143 | }, | 151 | }, |
144 | onDelete: 'cascade' | 152 | onDelete: 'set null' |
145 | }) | 153 | }) |
146 | Avatar: AvatarModel | 154 | Avatar: AvatarModel |
147 | 155 | ||
@@ -253,11 +261,7 @@ export class ActorModel extends Model<ActorModel> { | |||
253 | toFormattedJSON () { | 261 | toFormattedJSON () { |
254 | let avatar: Avatar = null | 262 | let avatar: Avatar = null |
255 | if (this.Avatar) { | 263 | if (this.Avatar) { |
256 | avatar = { | 264 | avatar = this.Avatar.toFormattedJSON() |
257 | path: join(AVATARS_DIR.ACCOUNT, this.Avatar.filename), | ||
258 | createdAt: this.Avatar.createdAt, | ||
259 | updatedAt: this.Avatar.updatedAt | ||
260 | } | ||
261 | } | 265 | } |
262 | 266 | ||
263 | let score: number | 267 | let score: number |
@@ -286,6 +290,16 @@ export class ActorModel extends Model<ActorModel> { | |||
286 | activityPubType = 'Group' as 'Group' | 290 | activityPubType = 'Group' as 'Group' |
287 | } | 291 | } |
288 | 292 | ||
293 | let icon = undefined | ||
294 | if (this.avatarId) { | ||
295 | const extension = extname(this.Avatar.filename) | ||
296 | icon = { | ||
297 | type: 'Image', | ||
298 | mediaType: extension === '.png' ? 'image/png' : 'image/jpeg', | ||
299 | url: this.getAvatarUrl() | ||
300 | } | ||
301 | } | ||
302 | |||
289 | const json = { | 303 | const json = { |
290 | type: activityPubType, | 304 | type: activityPubType, |
291 | id: this.url, | 305 | id: this.url, |
@@ -304,7 +318,8 @@ export class ActorModel extends Model<ActorModel> { | |||
304 | id: this.getPublicKeyUrl(), | 318 | id: this.getPublicKeyUrl(), |
305 | owner: this.url, | 319 | owner: this.url, |
306 | publicKeyPem: this.publicKey | 320 | publicKeyPem: this.publicKey |
307 | } | 321 | }, |
322 | icon | ||
308 | } | 323 | } |
309 | 324 | ||
310 | return activityPubContextify(json) | 325 | return activityPubContextify(json) |
@@ -353,4 +368,10 @@ export class ActorModel extends Model<ActorModel> { | |||
353 | getHost () { | 368 | getHost () { |
354 | return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST | 369 | return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST |
355 | } | 370 | } |
371 | |||
372 | getAvatarUrl () { | ||
373 | if (!this.avatarId) return undefined | ||
374 | |||
375 | return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath | ||
376 | } | ||
356 | } | 377 | } |
diff --git a/server/models/avatar/avatar.ts b/server/models/avatar/avatar.ts index 2e7a8ae2c..7493c3d75 100644 --- a/server/models/avatar/avatar.ts +++ b/server/models/avatar/avatar.ts | |||
@@ -1,4 +1,10 @@ | |||
1 | import { AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' | 1 | import { join } from 'path' |
2 | import { AfterDestroy, AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { Avatar } from '../../../shared/models/avatars/avatar.model' | ||
4 | import { unlinkPromise } from '../../helpers/core-utils' | ||
5 | import { logger } from '../../helpers/logger' | ||
6 | import { CONFIG, STATIC_PATHS } from '../../initializers' | ||
7 | import { sendDeleteVideo } from '../../lib/activitypub/send' | ||
2 | 8 | ||
3 | @Table({ | 9 | @Table({ |
4 | tableName: 'avatar' | 10 | tableName: 'avatar' |
@@ -14,4 +20,26 @@ export class AvatarModel extends Model<AvatarModel> { | |||
14 | 20 | ||
15 | @UpdatedAt | 21 | @UpdatedAt |
16 | updatedAt: Date | 22 | updatedAt: Date |
23 | |||
24 | @AfterDestroy | ||
25 | static removeFilesAndSendDelete (instance: AvatarModel) { | ||
26 | return instance.removeAvatar() | ||
27 | } | ||
28 | |||
29 | toFormattedJSON (): Avatar { | ||
30 | return { | ||
31 | path: this.getWebserverPath(), | ||
32 | createdAt: this.createdAt, | ||
33 | updatedAt: this.updatedAt | ||
34 | } | ||
35 | } | ||
36 | |||
37 | getWebserverPath () { | ||
38 | return join(STATIC_PATHS.AVATARS, this.filename) | ||
39 | } | ||
40 | |||
41 | removeAvatar () { | ||
42 | const avatarPath = join(CONFIG.STORAGE.AVATARS_DIR, this.filename) | ||
43 | return unlinkPromise(avatarPath) | ||
44 | } | ||
17 | } | 45 | } |
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index d381ccafa..829022a51 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts | |||
@@ -214,7 +214,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
214 | 214 | ||
215 | static listThreadCommentsForApi (videoId: number, threadId: number) { | 215 | static listThreadCommentsForApi (videoId: number, threadId: number) { |
216 | const query = { | 216 | const query = { |
217 | order: [ [ 'id', 'ASC' ] ], | 217 | order: [ [ 'createdAt', 'DESC' ] ], |
218 | where: { | 218 | where: { |
219 | videoId, | 219 | videoId, |
220 | [ Sequelize.Op.or ]: [ | 220 | [ Sequelize.Op.or ]: [ |
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts index 0c126dbff..44412ad82 100644 --- a/server/tests/api/check-params/users.ts +++ b/server/tests/api/check-params/users.ts | |||
@@ -2,11 +2,13 @@ | |||
2 | 2 | ||
3 | import { omit } from 'lodash' | 3 | import { omit } from 'lodash' |
4 | import 'mocha' | 4 | import 'mocha' |
5 | import { join } from "path" | ||
5 | import { UserRole } from '../../../../shared' | 6 | import { UserRole } from '../../../../shared' |
6 | 7 | ||
7 | import { | 8 | import { |
8 | createUser, flushTests, getMyUserInformation, getMyUserVideoRating, getUsersList, immutableAssign, killallServers, makeGetRequest, | 9 | createUser, flushTests, getMyUserInformation, getMyUserVideoRating, getUsersList, immutableAssign, killallServers, makeGetRequest, |
9 | makePostBodyRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers, updateUser, | 10 | makePostBodyRequest, makePostUploadRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers, |
11 | updateUser, | ||
10 | uploadVideo, userLogin | 12 | uploadVideo, userLogin |
11 | } from '../../utils' | 13 | } from '../../utils' |
12 | import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' | 14 | import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' |
@@ -266,6 +268,24 @@ describe('Test users API validators', function () { | |||
266 | }) | 268 | }) |
267 | }) | 269 | }) |
268 | 270 | ||
271 | describe('When updating my avatar', function () { | ||
272 | it('Should fail without an incorrect input file', async function () { | ||
273 | const fields = {} | ||
274 | const attaches = { | ||
275 | 'avatarfile': join(__dirname, '..', 'fixtures', 'video_short.mp4') | ||
276 | } | ||
277 | await makePostUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) | ||
278 | }) | ||
279 | |||
280 | it('Should succeed with the correct params', async function () { | ||
281 | const fields = {} | ||
282 | const attaches = { | ||
283 | 'avatarfile': join(__dirname, '..', 'fixtures', 'avatar.png') | ||
284 | } | ||
285 | await makePostUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) | ||
286 | }) | ||
287 | }) | ||
288 | |||
269 | describe('When updating a user', function () { | 289 | describe('When updating a user', function () { |
270 | 290 | ||
271 | before(async function () { | 291 | before(async function () { |
diff --git a/server/tests/api/fixtures/avatar.png b/server/tests/api/fixtures/avatar.png new file mode 100644 index 000000000..4b7fd2c0a --- /dev/null +++ b/server/tests/api/fixtures/avatar.png | |||
Binary files differ | |||
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 19549acdd..3390b2d56 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts | |||
@@ -6,7 +6,7 @@ import { UserRole } from '../../../../shared/index' | |||
6 | import { | 6 | import { |
7 | createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoRating, getUserInformation, getUsersList, | 7 | createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoRating, getUserInformation, getUsersList, |
8 | getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, registerUser, removeUser, removeVideo, | 8 | getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, registerUser, removeUser, removeVideo, |
9 | runServer, ServerInfo, serverLogin, updateMyUser, updateUser, uploadVideo | 9 | runServer, ServerInfo, serverLogin, testVideoImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo |
10 | } from '../../utils/index' | 10 | } from '../../utils/index' |
11 | import { follow } from '../../utils/server/follows' | 11 | import { follow } from '../../utils/server/follows' |
12 | import { setAccessTokensToServers } from '../../utils/users/login' | 12 | import { setAccessTokensToServers } from '../../utils/users/login' |
@@ -340,6 +340,22 @@ describe('Test users', function () { | |||
340 | expect(user.id).to.be.a('number') | 340 | expect(user.id).to.be.a('number') |
341 | }) | 341 | }) |
342 | 342 | ||
343 | it('Should be able to update my avatar', async function () { | ||
344 | const fixture = 'avatar.png' | ||
345 | |||
346 | await updateMyAvatar({ | ||
347 | url: server.url, | ||
348 | accessToken: accessTokenUser, | ||
349 | fixture | ||
350 | }) | ||
351 | |||
352 | const res = await getMyUserInformation(server.url, accessTokenUser) | ||
353 | const user = res.body | ||
354 | |||
355 | const test = await testVideoImage(server.url, 'avatar', user.account.avatar.path, '.png') | ||
356 | expect(test).to.equal(true) | ||
357 | }) | ||
358 | |||
343 | it('Should be able to update another user', async function () { | 359 | it('Should be able to update another user', async function () { |
344 | await updateUser({ | 360 | await updateUser({ |
345 | url: server.url, | 361 | url: server.url, |
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts index e0cca3f51..90b1ca0a6 100644 --- a/server/tests/utils/users/users.ts +++ b/server/tests/utils/users/users.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | import { isAbsolute, join } from 'path' | ||
1 | import * as request from 'supertest' | 2 | import * as request from 'supertest' |
2 | import { makePutBodyRequest } from '../' | 3 | import { makePostUploadRequest, makePutBodyRequest } from '../' |
3 | 4 | ||
4 | import { UserRole } from '../../../../shared/index' | 5 | import { UserRole } from '../../../../shared/index' |
5 | 6 | ||
@@ -137,6 +138,29 @@ function updateMyUser (options: { | |||
137 | }) | 138 | }) |
138 | } | 139 | } |
139 | 140 | ||
141 | function updateMyAvatar (options: { | ||
142 | url: string, | ||
143 | accessToken: string, | ||
144 | fixture: string | ||
145 | }) { | ||
146 | const path = '/api/v1/users/me/avatar/pick' | ||
147 | let filePath = '' | ||
148 | if (isAbsolute(options.fixture)) { | ||
149 | filePath = options.fixture | ||
150 | } else { | ||
151 | filePath = join(__dirname, '..', '..', 'api', 'fixtures', options.fixture) | ||
152 | } | ||
153 | |||
154 | return makePostUploadRequest({ | ||
155 | url: options.url, | ||
156 | path, | ||
157 | token: options.accessToken, | ||
158 | fields: {}, | ||
159 | attaches: { avatarfile: filePath }, | ||
160 | statusCodeExpected: 200 | ||
161 | }) | ||
162 | } | ||
163 | |||
140 | function updateUser (options: { | 164 | function updateUser (options: { |
141 | url: string | 165 | url: string |
142 | userId: number, | 166 | userId: number, |
@@ -173,5 +197,6 @@ export { | |||
173 | removeUser, | 197 | removeUser, |
174 | updateUser, | 198 | updateUser, |
175 | updateMyUser, | 199 | updateMyUser, |
176 | getUserInformation | 200 | getUserInformation, |
201 | updateMyAvatar | ||
177 | } | 202 | } |
diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts index d6bf27dc7..aca51ee5d 100644 --- a/server/tests/utils/videos/videos.ts +++ b/server/tests/utils/videos/videos.ts | |||
@@ -201,7 +201,7 @@ function searchVideoWithSort (url: string, search: string, sort: string) { | |||
201 | .expect('Content-Type', /json/) | 201 | .expect('Content-Type', /json/) |
202 | } | 202 | } |
203 | 203 | ||
204 | async function testVideoImage (url: string, imageName: string, imagePath: string) { | 204 | async function testVideoImage (url: string, imageName: string, imagePath: string, extension = '.jpg') { |
205 | // Don't test images if the node env is not set | 205 | // Don't test images if the node env is not set |
206 | // Because we need a special ffmpeg version for this test | 206 | // Because we need a special ffmpeg version for this test |
207 | if (process.env['NODE_TEST_IMAGE']) { | 207 | if (process.env['NODE_TEST_IMAGE']) { |
@@ -209,7 +209,7 @@ async function testVideoImage (url: string, imageName: string, imagePath: string | |||
209 | .get(imagePath) | 209 | .get(imagePath) |
210 | .expect(200) | 210 | .expect(200) |
211 | 211 | ||
212 | const data = await readFilePromise(join(__dirname, '..', '..', 'api', 'fixtures', imageName + '.jpg')) | 212 | const data = await readFilePromise(join(__dirname, '..', '..', 'api', 'fixtures', imageName + extension)) |
213 | 213 | ||
214 | return data.equals(res.body) | 214 | return data.equals(res.body) |
215 | } else { | 215 | } else { |
diff --git a/shared/models/activitypub/activitypub-actor.ts b/shared/models/activitypub/activitypub-actor.ts index d9f80b94c..78256e9be 100644 --- a/shared/models/activitypub/activitypub-actor.ts +++ b/shared/models/activitypub/activitypub-actor.ts | |||
@@ -27,6 +27,10 @@ export interface ActivityPubActor { | |||
27 | } | 27 | } |
28 | 28 | ||
29 | // Not used | 29 | // Not used |
30 | // icon: string[] | 30 | icon: { |
31 | type: 'Image' | ||
32 | mediaType: 'image/png' | ||
33 | url: string | ||
34 | } | ||
31 | // liked: string | 35 | // liked: string |
32 | } | 36 | } |
diff --git a/shared/models/actors/account.model.ts b/shared/models/actors/account.model.ts index d14701317..ef6fca539 100644 --- a/shared/models/actors/account.model.ts +++ b/shared/models/actors/account.model.ts | |||
@@ -4,6 +4,7 @@ export interface Account { | |||
4 | id: number | 4 | id: number |
5 | uuid: string | 5 | uuid: string |
6 | name: string | 6 | name: string |
7 | displayName: string | ||
7 | host: string | 8 | host: string |
8 | followingCount: number | 9 | followingCount: number |
9 | followersCount: number | 10 | followersCount: number |