aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2017-12-29 19:10:13 +0100
committerChocobozzz <me@florianbigard.com>2017-12-29 19:10:13 +0100
commitc5911fd347c76e8bdc05ea9f3ee9efed4a58c236 (patch)
treeb8d287daca6c45305090cbec9da97d1155f275bd
parent8b0d42ee372de6589796be26b83e5bffb1b69cdf (diff)
downloadPeerTube-c5911fd347c76e8bdc05ea9f3ee9efed4a58c236.tar.gz
PeerTube-c5911fd347c76e8bdc05ea9f3ee9efed4a58c236.tar.zst
PeerTube-c5911fd347c76e8bdc05ea9f3ee9efed4a58c236.zip
Begin to add avatar to actors
-rw-r--r--.gitignore1
-rw-r--r--client/src/app/account/account-settings/account-settings.component.html6
-rw-r--r--client/src/app/account/account-settings/account-settings.component.scss6
-rw-r--r--client/src/app/account/account-settings/account-settings.component.ts36
-rw-r--r--client/src/app/account/account-videos/account-videos.component.ts4
-rw-r--r--client/src/app/core/auth/auth.service.ts35
-rw-r--r--client/src/app/menu/menu.component.html2
-rw-r--r--client/src/app/menu/menu.component.ts4
-rw-r--r--client/src/app/shared/account/account.model.ts10
-rw-r--r--client/src/app/shared/misc/utils.ts14
-rw-r--r--client/src/app/shared/users/user.model.ts4
-rw-r--r--client/src/app/shared/users/user.service.ts16
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts2
-rw-r--r--client/src/app/shared/video/video.model.ts7
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.scss5
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.scss25
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts8
-rw-r--r--client/src/sass/include/_mixins.scss26
-rw-r--r--server/controllers/activitypub/client.ts6
-rw-r--r--server/controllers/api/users.ts53
-rw-r--r--server/controllers/api/videos/index.ts27
-rw-r--r--server/controllers/static.ts6
-rw-r--r--server/helpers/custom-validators/users.ts21
-rw-r--r--server/helpers/utils.ts30
-rw-r--r--server/initializers/constants.ts19
-rw-r--r--server/initializers/migrations/0150-avatar-cascade.ts28
-rw-r--r--server/lib/activitypub/actor.ts87
-rw-r--r--server/lib/activitypub/url.ts2
-rw-r--r--server/middlewares/validators/users.ts22
-rw-r--r--server/models/account/account.ts6
-rw-r--r--server/models/account/user.ts8
-rw-r--r--server/models/activitypub/actor.ts37
-rw-r--r--server/models/avatar/avatar.ts30
-rw-r--r--server/models/video/video-comment.ts2
-rw-r--r--server/tests/api/check-params/users.ts22
-rw-r--r--server/tests/api/fixtures/avatar.pngbin0 -> 1674 bytes
-rw-r--r--server/tests/api/users/users.ts18
-rw-r--r--server/tests/utils/users/users.ts29
-rw-r--r--server/tests/utils/videos/videos.ts4
-rw-r--r--shared/models/activitypub/activitypub-actor.ts6
-rw-r--r--shared/models/actors/account.model.ts1
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 @@
1import { Component, OnInit } from '@angular/core' 1import { HttpEventType, HttpResponse } from '@angular/common/http'
2import { Component, OnInit, ViewChild } from '@angular/core'
3import { NotificationsService } from 'angular2-notifications'
4import { VideoPrivacy } from '../../../../../shared/models/videos'
2import { User } from '../../shared' 5import { User } from '../../shared'
3import { AuthService } from '../../core' 6import { AuthService } from '../../core'
7import { 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})
10export class AccountSettingsComponent implements OnInit { 14export 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'
9import { Observable } from 'rxjs/Observable' 9import { Observable } from 'rxjs/Observable'
10import { ReplaySubject } from 'rxjs/ReplaySubject' 10import { ReplaySubject } from 'rxjs/ReplaySubject'
11import { Subject } from 'rxjs/Subject' 11import { Subject } from 'rxjs/Subject'
12import { OAuthClientLocal, User as UserServerModel, UserRefreshToken, UserRole, VideoChannel } from '../../../../../shared' 12import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared'
13import { Account } from '../../../../../shared/models/actors' 13import { User } from '../../../../../shared/models/users'
14import { UserLogin } from '../../../../../shared/models/users/user-login.model' 14import { UserLogin } from '../../../../../shared/models/users/user-login.model'
15import { environment } from '../../../environments/environment' 15import { environment } from '../../../environments/environment'
16import { RestExtractor } from '../../shared/rest' 16import { RestExtractor } from '../../shared/rest'
@@ -25,20 +25,7 @@ interface UserLoginWithUsername extends UserLogin {
25 username: string 25 username: string
26} 26}
27 27
28interface UserLoginWithUserInformation extends UserLogin { 28type 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()
44export class AuthService { 31export 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 @@
1import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' 1import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model'
2import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 2import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
3import { environment } from '../../../environments/environment' 3import { environment } from '../../../environments/environment'
4import { getAbsoluteAPIUrl } from '../misc/utils'
4 5
5export class Account implements ServerAccount { 6export 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
3import { environment } from '../../../environments/environment'
3import { AuthService } from '../../core/auth' 4import { AuthService } from '../../core/auth'
4 5
5function getParameterByName (name: string, url: string) { 6function getParameterByName (name: string, url: string) {
@@ -38,8 +39,19 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: any[
38 }) 39 })
39} 40}
40 41
42function 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
41export { 52export {
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'
5import { UserCreate, UserUpdateMe } from '../../../../../shared' 5import { UserCreate, UserUpdateMe } from '../../../../../shared'
6import { environment } from '../../../environments/environment' 6import { environment } from '../../../environments/environment'
7import { RestExtractor } from '../rest' 7import { RestExtractor } from '../rest'
8import { User } from './user.model'
8 9
9@Injectable() 10@Injectable()
10export class UserService { 11export 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 '../'
2import { Video as VideoServerModel } from '../../../../../shared' 2import { Video as VideoServerModel } from '../../../../../shared'
3import { Account } from '../../../../../shared/models/actors' 3import { Account } from '../../../../../shared/models/actors'
4import { environment } from '../../../environments/environment' 4import { environment } from '../../../environments/environment'
5import { getAbsoluteAPIUrl } from '../misc/utils'
5 6
6export class Video implements VideoServerModel { 7export 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
17const activityPubClientRouter = express.Router() 17const activityPubClientRouter = express.Router()
18 18
19activityPubClientRouter.get('/account/:name', 19activityPubClientRouter.get('/accounts/:name',
20 executeIfActivityPub(asyncMiddleware(localAccountValidator)), 20 executeIfActivityPub(asyncMiddleware(localAccountValidator)),
21 executeIfActivityPub(accountController) 21 executeIfActivityPub(accountController)
22) 22)
23 23
24activityPubClientRouter.get('/account/:name/followers', 24activityPubClientRouter.get('/accounts/:name/followers',
25 executeIfActivityPub(asyncMiddleware(localAccountValidator)), 25 executeIfActivityPub(asyncMiddleware(localAccountValidator)),
26 executeIfActivityPub(asyncMiddleware(accountFollowersController)) 26 executeIfActivityPub(asyncMiddleware(accountFollowersController))
27) 27)
28 28
29activityPubClientRouter.get('/account/:name/following', 29activityPubClientRouter.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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { extname, join } from 'path'
3import * as uuidv4 from 'uuid/v4'
2import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../shared' 4import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../shared'
5import { renamePromise } from '../../helpers/core-utils'
3import { retryTransactionWrapper } from '../../helpers/database-utils' 6import { retryTransactionWrapper } from '../../helpers/database-utils'
4import { logger } from '../../helpers/logger' 7import { logger } from '../../helpers/logger'
5import { getFormattedObjects } from '../../helpers/utils' 8import { createReqFiles, getFormattedObjects } from '../../helpers/utils'
6import { CONFIG } from '../../initializers' 9import { AVATAR_MIMETYPE_EXT, CONFIG, sequelizeTypescript } from '../../initializers'
7import { createUserAccountAndChannel } from '../../lib/user' 10import { createUserAccountAndChannel } from '../../lib/user'
8import { 11import {
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'
13import { videosSortValidator } from '../../middlewares/validators' 16import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators'
14import { AccountVideoRateModel } from '../../models/account/account-video-rate' 17import { AccountVideoRateModel } from '../../models/account/account-video-rate'
15import { UserModel } from '../../models/account/user' 18import { UserModel } from '../../models/account/user'
19import { AvatarModel } from '../../models/avatar/avatar'
16import { VideoModel } from '../../models/video/video' 20import { VideoModel } from '../../models/video/video'
17 21
22const reqAvatarFile = createReqFiles('avatarfile', CONFIG.STORAGE.AVATARS_DIR, AVATAR_MIMETYPE_EXT)
23
18const usersRouter = express.Router() 24const usersRouter = express.Router()
19 25
20usersRouter.get('/me', 26usersRouter.get('/me',
@@ -71,6 +77,13 @@ usersRouter.put('/me',
71 asyncMiddleware(updateMe) 77 asyncMiddleware(updateMe)
72) 78)
73 79
80usersRouter.post('/me/avatar/pick',
81 authenticate,
82 reqAvatarFile,
83 usersUpdateMyAvatarValidator,
84 asyncMiddleware(updateMyAvatar)
85)
86
74usersRouter.put('/:id', 87usersRouter.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
232async 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
219async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) { 266async 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'
6import { retryTransactionWrapper } from '../../../helpers/database-utils' 6import { retryTransactionWrapper } from '../../../helpers/database-utils'
7import { getVideoFileHeight } from '../../../helpers/ffmpeg-utils' 7import { getVideoFileHeight } from '../../../helpers/ffmpeg-utils'
8import { logger } from '../../../helpers/logger' 8import { logger } from '../../../helpers/logger'
9import { generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils' 9import { createReqFiles, generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils'
10import { 10import {
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
30const videosRouter = express.Router() 30const videosRouter = express.Router()
31 31
32// multer configuration 32const reqVideoFile = createReqFiles('videofile', CONFIG.STORAGE.VIDEOS_DIR, VIDEO_MIMETYPE_EXT)
33const 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
53const reqFiles = multer({ storage: storage }).fields([{ name: 'videofile', maxCount: 1 }])
54 33
55videosRouter.use('/', abuseVideoRouter) 34videosRouter.use('/', abuseVideoRouter)
56videosRouter.use('/', blacklistRouter) 35videosRouter.use('/', blacklistRouter)
@@ -85,7 +64,7 @@ videosRouter.put('/:id',
85) 64)
86videosRouter.post('/upload', 65videosRouter.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
35const avatarsPhysicalPath = CONFIG.STORAGE.AVATARS_DIR
36staticRouter.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
36staticRouter.use( 42staticRouter.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 @@
1import * as validator from 'validator' 1import * as validator from 'validator'
2import 'express-validator' 2import 'express-validator'
3 3
4import { exists } from './misc' 4import { exists, isArray } from './misc'
5import { CONSTRAINTS_FIELDS } from '../../initializers' 5import { CONSTRAINTS_FIELDS } from '../../initializers'
6import { UserRole } from '../../../shared' 6import { 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
40function 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
42export { 58export {
@@ -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 @@
1import * as express from 'express' 1import * as express from 'express'
2import * as multer from 'multer'
2import { Model } from 'sequelize-typescript' 3import { Model } from 'sequelize-typescript'
3import { ResultList } from '../../shared' 4import { ResultList } from '../../shared'
4import { VideoResolution } from '../../shared/models/videos' 5import { VideoResolution } from '../../shared/models/videos'
5import { CONFIG, REMOTE_SCHEME } from '../initializers' 6import { CONFIG, REMOTE_SCHEME, VIDEO_MIMETYPE_EXT } from '../initializers'
6import { UserModel } from '../models/account/user' 7import { UserModel } from '../models/account/user'
7import { ActorModel } from '../models/activitypub/actor' 8import { ActorModel } from '../models/activitypub/actor'
8import { ApplicationModel } from '../models/application/application' 9import { 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
30function 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
29async function generateRandomString (size: number) { 54async 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
12const LAST_MIGRATION_VERSION = 145 12const 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
256const AVATAR_MIMETYPE_EXT = {
257 'image/png': '.png',
258 'image/jpg': '.jpg',
259 'image/jpeg': '.jpg'
260}
261
253// --------------------------------------------------------------------------- 262// ---------------------------------------------------------------------------
254 263
255const SERVER_ACTOR_NAME = 'peertube' 264const 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 @@
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 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
21function down (options) {
22 throw new Error('Not implemented.')
23}
24
25export {
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 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { join } from 'path'
2import { Transaction } from 'sequelize' 3import { Transaction } from 'sequelize'
3import * as url from 'url' 4import * as url from 'url'
5import * as uuidv4 from 'uuid/v4'
4import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' 6import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub'
5import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' 7import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
6import { isRemoteActorValid } from '../../helpers/custom-validators/activitypub/actor' 8import { isRemoteActorValid } from '../../helpers/custom-validators/activitypub/actor'
9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
7import { retryTransactionWrapper } from '../../helpers/database-utils' 10import { retryTransactionWrapper } from '../../helpers/database-utils'
8import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
9import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto' 12import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
10import { doRequest } from '../../helpers/requests' 13import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests'
11import { CONFIG, sequelizeTypescript } from '../../initializers' 14import { CONFIG, sequelizeTypescript } from '../../initializers'
12import { AccountModel } from '../../models/account/account' 15import { AccountModel } from '../../models/account/account'
13import { ActorModel } from '../../models/activitypub/actor' 16import { ActorModel } from '../../models/activitypub/actor'
17import { AvatarModel } from '../../models/avatar/avatar'
14import { ServerModel } from '../../models/server/server' 18import { ServerModel } from '../../models/server/server'
15import { VideoChannelModel } from '../../models/video/video-channel' 19import { 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
69function 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
87export {
88 getOrCreateActorAndServerAndModel,
89 buildActorInstance,
90 setAsyncActorKeys
91}
92
93// ---------------------------------------------------------------------------
94
65function saveActorAndServerAndModelIfNotExist ( 95function 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}
117async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResult> { 156async 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
163function 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
181export {
182 getOrCreateActorAndServerAndModel,
183 saveActorAndServerAndModelIfNotExist,
184 fetchRemoteActor,
185 buildActorInstance,
186 setAsyncActorKeys
187}
188
189// ---------------------------------------------------------------------------
190
191async function fetchActorTotalItems (url: string) { 220async 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
20function getAccountActivityPubUrl (accountName: string) { 20function getAccountActivityPubUrl (accountName: string) {
21 return CONFIG.WEBSERVER.URL + '/account/' + accountName 21 return CONFIG.WEBSERVER.URL + '/accounts/' + accountName
22} 22}
23 23
24function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) { 24function 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'
3import { body, param } from 'express-validator/check' 3import { body, param } from 'express-validator/check'
4import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 4import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
5import { 5import {
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'
9import { isVideoExist } from '../../helpers/custom-validators/videos' 10import { isVideoExist, isVideoFile } from '../../helpers/custom-validators/videos'
10import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
11import { isSignupAllowed } from '../../helpers/utils' 12import { isSignupAllowed } from '../../helpers/utils'
13import { CONSTRAINTS_FIELDS } from '../../initializers'
12import { UserModel } from '../../models/account/user' 14import { UserModel } from '../../models/account/user'
13import { areValidationErrors } from './utils' 15import { areValidationErrors } from './utils'
14 16
@@ -96,6 +98,21 @@ const usersUpdateMeValidator = [
96 } 98 }
97] 99]
98 100
101const 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
99const usersGetValidator = [ 116const 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'
16import { Account } from '../../../shared/models/actors'
16import { isUserUsernameValid } from '../../helpers/custom-validators/users' 17import { isUserUsernameValid } from '../../helpers/custom-validators/users'
17import { sendDeleteActor } from '../../lib/activitypub/send' 18import { sendDeleteActor } from '../../lib/activitypub/send'
18import { ActorModel } from '../activitypub/actor' 19import { 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'
6import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' 6import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
7import { User } from '../../../shared/models/users'
7import { 8import {
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 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import { join } from 'path' 2import { extname, join } from 'path'
3import * as Sequelize from 'sequelize' 3import * as Sequelize from 'sequelize'
4import { 4import {
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 @@
1import { AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { join } from 'path'
2import { AfterDestroy, AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { Avatar } from '../../../shared/models/avatars/avatar.model'
4import { unlinkPromise } from '../../helpers/core-utils'
5import { logger } from '../../helpers/logger'
6import { CONFIG, STATIC_PATHS } from '../../initializers'
7import { 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
3import { omit } from 'lodash' 3import { omit } from 'lodash'
4import 'mocha' 4import 'mocha'
5import { join } from "path"
5import { UserRole } from '../../../../shared' 6import { UserRole } from '../../../../shared'
6 7
7import { 8import {
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'
12import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' 14import { 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'
6import { 6import {
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'
11import { follow } from '../../utils/server/follows' 11import { follow } from '../../utils/server/follows'
12import { setAccessTokensToServers } from '../../utils/users/login' 12import { 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 @@
1import { isAbsolute, join } from 'path'
1import * as request from 'supertest' 2import * as request from 'supertest'
2import { makePutBodyRequest } from '../' 3import { makePostUploadRequest, makePutBodyRequest } from '../'
3 4
4import { UserRole } from '../../../../shared/index' 5import { UserRole } from '../../../../shared/index'
5 6
@@ -137,6 +138,29 @@ function updateMyUser (options: {
137 }) 138 })
138} 139}
139 140
141function 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
140function updateUser (options: { 164function 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
204async function testVideoImage (url: string, imageName: string, imagePath: string) { 204async 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