Begin to add avatar to actors
authorChocobozzz <me@florianbigard.com>
Fri, 29 Dec 2017 18:10:13 +0000 (19:10 +0100)
committerChocobozzz <me@florianbigard.com>
Fri, 29 Dec 2017 18:10:13 +0000 (19:10 +0100)
41 files changed:
.gitignore
client/src/app/account/account-settings/account-settings.component.html
client/src/app/account/account-settings/account-settings.component.scss
client/src/app/account/account-settings/account-settings.component.ts
client/src/app/account/account-videos/account-videos.component.ts
client/src/app/core/auth/auth.service.ts
client/src/app/menu/menu.component.html
client/src/app/menu/menu.component.ts
client/src/app/shared/account/account.model.ts
client/src/app/shared/misc/utils.ts
client/src/app/shared/users/user.model.ts
client/src/app/shared/users/user.service.ts
client/src/app/shared/video/abstract-video-list.ts
client/src/app/shared/video/video.model.ts
client/src/app/videos/+video-edit/shared/video-edit.component.scss
client/src/app/videos/+video-edit/video-add.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/sass/include/_mixins.scss
server/controllers/activitypub/client.ts
server/controllers/api/users.ts
server/controllers/api/videos/index.ts
server/controllers/static.ts
server/helpers/custom-validators/users.ts
server/helpers/utils.ts
server/initializers/constants.ts
server/initializers/migrations/0150-avatar-cascade.ts [new file with mode: 0644]
server/lib/activitypub/actor.ts
server/lib/activitypub/url.ts
server/middlewares/validators/users.ts
server/models/account/account.ts
server/models/account/user.ts
server/models/activitypub/actor.ts
server/models/avatar/avatar.ts
server/models/video/video-comment.ts
server/tests/api/check-params/users.ts
server/tests/api/fixtures/avatar.png [new file with mode: 0644]
server/tests/api/users/users.ts
server/tests/utils/users/users.ts
server/tests/utils/videos/videos.ts
shared/models/activitypub/activitypub-actor.ts
shared/models/actors/account.model.ts

index 62e252782bc9e118dd072bed7717ce2049e0dda0..5d882360db7b41b047e1a018840cf6910baede03 100644 (file)
@@ -7,6 +7,7 @@
 /test6/
 /uploads/
 /videos/
+/avatars/
 /thumbnails/
 /previews/
 /certs/
index f14eadd498a93ae940daf147f147eeb04cdfe044..fe345207a668305c9b4ca2a9c321724dabb6e345 100644 (file)
@@ -1,5 +1,5 @@
 <div class="user">
-  <img [src]="getAvatarPath()" alt="Avatar" />
+  <img [src]="getAvatarUrl()" alt="Avatar" />
 
   <div class="user-info">
     <div class="user-info-username">{{ user.username }}</div>
@@ -7,6 +7,10 @@
   </div>
 </div>
 
+<div class="button-file">
+  <span>Change your avatar</span>
+  <input #avatarfileInput type="file" name="avatarfile" id="avatarfile" (change)="changeAvatar()" />
+</div>
 
 <div class="account-title">Account settings</div>
 <my-account-change-password></my-account-change-password>
index 7f1ade377e50872a79fbdd3b22fc4a8b15853acc..accd65214ff13c82447d7358451bdafa5c94e926 100644 (file)
   }
 }
 
+.button-file {
+  @include peertube-button-file(auto);
+
+  margin-top: 10px;
+}
+
 .account-title {
   text-transform: uppercase;
   color: $orange-color;
index cba2510009b0d5a4803b7a9ec9d4a2c61c19f7e9..3e03085cedad88c5a2923356ae19edcae1c43e8d 100644 (file)
@@ -1,6 +1,10 @@
-import { Component, OnInit } from '@angular/core'
+import { HttpEventType, HttpResponse } from '@angular/common/http'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { NotificationsService } from 'angular2-notifications'
+import { VideoPrivacy } from '../../../../../shared/models/videos'
 import { User } from '../../shared'
 import { AuthService } from '../../core'
+import { UserService } from '../../shared/users'
 
 @Component({
   selector: 'my-account-settings',
@@ -8,15 +12,39 @@ import { AuthService } from '../../core'
   styleUrls: [ './account-settings.component.scss' ]
 })
 export class AccountSettingsComponent implements OnInit {
+  @ViewChild('avatarfileInput') avatarfileInput
+
   user: User = null
 
-  constructor (private authService: AuthService) {}
+  constructor (
+    private userService: UserService,
+    private authService: AuthService,
+    private notificationsService: NotificationsService
+  ) {}
 
   ngOnInit () {
     this.user = this.authService.getUser()
   }
 
-  getAvatarPath () {
-    return this.user.getAvatarPath()
+  getAvatarUrl () {
+    return this.user.getAvatarUrl()
+  }
+
+  changeAvatar () {
+    const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
+
+    const formData = new FormData()
+    formData.append('avatarfile', avatarfile)
+
+    this.userService.changeAvatar(formData)
+      .subscribe(
+        data => {
+          this.notificationsService.success('Success', 'Avatar changed.')
+
+          this.user.account.avatar = data.avatar
+        },
+
+        err => this.notificationsService.error('Error', err.message)
+      )
   }
 }
index 22941619d337f9b6bf922aaa0ee0654a47311640..d51b70e067cc326cf776bdebdafac95427b9b57f 100644 (file)
@@ -68,7 +68,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
           .subscribe(
             res => this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`),
 
-          err => this.notificationsService.error('Error', err.text)
+          err => this.notificationsService.error('Error', err.message)
           )
       }
     )
@@ -86,7 +86,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
               this.spliceVideosById(video.id)
             },
 
-            error => this.notificationsService.error('Error', error.text)
+            error => this.notificationsService.error('Error', error.message)
           )
       }
     )
index c914848aeb9e609a25197593b63e6d38c46a3953..8a2ba77d6b16d30bb40f6bd47f530df4cccab392 100644 (file)
@@ -9,8 +9,8 @@ import 'rxjs/add/operator/mergeMap'
 import { Observable } from 'rxjs/Observable'
 import { ReplaySubject } from 'rxjs/ReplaySubject'
 import { Subject } from 'rxjs/Subject'
-import { OAuthClientLocal, User as UserServerModel, UserRefreshToken, UserRole, VideoChannel } from '../../../../../shared'
-import { Account } from '../../../../../shared/models/actors'
+import { OAuthClientLocal, User as UserServerModel, UserRefreshToken } from '../../../../../shared'
+import { User } from '../../../../../shared/models/users'
 import { UserLogin } from '../../../../../shared/models/users/user-login.model'
 import { environment } from '../../../environments/environment'
 import { RestExtractor } from '../../shared/rest'
@@ -25,20 +25,7 @@ interface UserLoginWithUsername extends UserLogin {
   username: string
 }
 
-interface UserLoginWithUserInformation extends UserLogin {
-  access_token: string
-  refresh_token: string
-  token_type: string
-  username: string
-  id: number
-  role: UserRole
-  displayNSFW: boolean
-  autoPlayVideo: boolean
-  email: string
-  videoQuota: number
-  account: Account
-  videoChannels: VideoChannel[]
-}
+type UserLoginWithUserInformation = UserLoginWithUsername & User
 
 @Injectable()
 export class AuthService {
@@ -209,21 +196,7 @@ export class AuthService {
     const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
 
     return this.http.get<UserServerModel>(AuthService.BASE_USER_INFORMATION_URL, { headers })
-                    .map(res => {
-                      const newProperties = {
-                        id: res.id,
-                        role: res.role,
-                        displayNSFW: res.displayNSFW,
-                        autoPlayVideo: res.autoPlayVideo,
-                        email: res.email,
-                        videoQuota: res.videoQuota,
-                        account: res.account,
-                        videoChannels: res.videoChannels
-                      }
-
-                      return Object.assign(obj, newProperties)
-                    }
-    )
+                    .map(res => Object.assign(obj, res))
   }
 
   private handleLogin (obj: UserLoginWithUserInformation) {
index 6f52f4f45cbca6e7d7406256c6c885e1d3f579ae..5ea859fd2c144dbfaecc599423bc98055fdae07f 100644 (file)
@@ -1,6 +1,6 @@
 <menu>
   <div *ngIf="isLoggedIn" class="logged-in-block">
-    <img [src]="getUserAvatarPath()" alt="Avatar" />
+    <img [src]="getUserAvatarUrl()" alt="Avatar" />
 
     <div class="logged-in-info">
       <a routerLink="/account/settings" class="logged-in-username">{{ user.username }}</a>
index 8b8b714a807139a9619429eaef445d8d382023fb..1f66e37542e762dee76fcf558422773cd005196b 100644 (file)
@@ -51,8 +51,8 @@ export class MenuComponent implements OnInit {
     )
   }
 
-  getUserAvatarPath () {
-    return this.user.getAvatarPath()
+  getUserAvatarUrl () {
+    return this.user.getAvatarUrl()
   }
 
   isRegistrationAllowed () {
index bacaa208ada837b33cfd6c352b4b207a012daca8..cc46dad77d1cca24e6d371ab183787f3d60edf54 100644 (file)
@@ -1,11 +1,13 @@
 import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model'
 import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
 import { environment } from '../../../environments/environment'
+import { getAbsoluteAPIUrl } from '../misc/utils'
 
 export class Account implements ServerAccount {
   id: number
   uuid: string
   name: string
+  displayName: string
   host: string
   followingCount: number
   followersCount: number
@@ -13,9 +15,11 @@ export class Account implements ServerAccount {
   updatedAt: Date
   avatar: Avatar
 
-  static GET_ACCOUNT_AVATAR_PATH (account: Account) {
-    if (account && account.avatar) return account.avatar.path
+  static GET_ACCOUNT_AVATAR_URL (account: Account) {
+    const absoluteAPIUrl = getAbsoluteAPIUrl()
 
-    return '/client/assets/images/default-avatar.png'
+    if (account && account.avatar) return absoluteAPIUrl + account.avatar.path
+
+    return window.location.origin + '/client/assets/images/default-avatar.png'
   }
 }
index 5525e4efbd28b1b1de4c9374671f955d8d066c6f..2739ff81a037ec0141812ec3bc0f3e8cbb1af7af 100644 (file)
@@ -1,5 +1,6 @@
 // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
 
+import { environment } from '../../../environments/environment'
 import { AuthService } from '../../core/auth'
 
 function getParameterByName (name: string, url: string) {
@@ -38,8 +39,19 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: any[
   })
 }
 
+function getAbsoluteAPIUrl () {
+  let absoluteAPIUrl = environment.apiUrl
+  if (!absoluteAPIUrl) {
+    // The API is on the same domain
+    absoluteAPIUrl = window.location.origin
+  }
+
+  return absoluteAPIUrl
+}
+
 export {
   viewportHeight,
   getParameterByName,
-  populateAsyncUserVideoChannels
+  populateAsyncUserVideoChannels,
+  getAbsoluteAPIUrl
 }
index 7a962ae3e424ea847a035ecb6e59e828e1d7eb03..83aae44639ce73fe1e2fb623a1bf70100b49e443 100644 (file)
@@ -57,7 +57,7 @@ export class User implements UserServerModel {
     return hasUserRight(this.role, right)
   }
 
-  getAvatarPath () {
-    return Account.GET_ACCOUNT_AVATAR_PATH(this.account)
+  getAvatarUrl () {
+    return Account.GET_ACCOUNT_AVATAR_URL(this.account)
   }
 }
index d97edbcbe8b0c3bf184c23bd6df67a2eca61e878..58ddaa5ee8d7b184155150692e6c41021152bec1 100644 (file)
@@ -5,6 +5,7 @@ import 'rxjs/add/operator/map'
 import { UserCreate, UserUpdateMe } from '../../../../../shared'
 import { environment } from '../../../environments/environment'
 import { RestExtractor } from '../rest'
+import { User } from './user.model'
 
 @Injectable()
 export class UserService {
@@ -34,9 +35,24 @@ export class UserService {
                         .catch(res => this.restExtractor.handleError(res))
   }
 
+  changeAvatar (avatarForm: FormData) {
+    const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
+
+    return this.authHttp.post(url, avatarForm)
+                        .catch(this.restExtractor.handleError)
+  }
+
   signup (userCreate: UserCreate) {
     return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
                         .map(this.restExtractor.extractDataBool)
                         .catch(res => this.restExtractor.handleError(res))
   }
+
+  getMyInformation () {
+    const url = UserService.BASE_USERS_URL + 'me'
+
+    return this.authHttp.get(url)
+      .map((userHash: any) => new User(userHash))
+      .catch(res => this.restExtractor.handleError(res))
+  }
 }
index bfe46bcdd23929d495d750403c6351d651daacde..354373776245daf6bc8d1e7ca40f577ada62bc92 100644 (file)
@@ -83,7 +83,7 @@ export abstract class AbstractVideoList implements OnInit {
           this.videos = this.videos.concat(videos)
         }
       },
-      error => this.notificationsService.error('Error', error.text)
+      error => this.notificationsService.error('Error', error.message)
     )
   }
 
index f159464c5d8cc4e80b00b876499e8786b7cdd7c1..060bf933f316d4d9f16be62ba56e93dd42a03bcc 100644 (file)
@@ -2,6 +2,7 @@ import { User } from '../'
 import { Video as VideoServerModel } from '../../../../../shared'
 import { Account } from '../../../../../shared/models/actors'
 import { environment } from '../../../environments/environment'
+import { getAbsoluteAPIUrl } from '../misc/utils'
 
 export class Video implements VideoServerModel {
   accountName: string
@@ -48,11 +49,7 @@ export class Video implements VideoServerModel {
   }
 
   constructor (hash: VideoServerModel) {
-    let absoluteAPIUrl = environment.apiUrl
-    if (!absoluteAPIUrl) {
-      // The API is on the same domain
-      absoluteAPIUrl = window.location.origin
-    }
+    const absoluteAPIUrl = getAbsoluteAPIUrl()
 
     this.accountName = hash.accountName
     this.createdAt = new Date(hash.createdAt.toString())
index 81e3a0d19b634c019a99608e099b3f0662d5d092..0fefcee28d94c1f1af142911380ed11afddcfaf5 100644 (file)
     }
   }
 }
-
-.little-information {
-  font-size: 0.8em;
-  font-style: italic;
-}
index 891f38819d32f74fc42c895c68b296e4cb0d966a..4bb50900957364a95bb3ac6ce5499b5d882fdc64 100644 (file)
     }
 
     .button-file {
-      position: relative;
-      overflow: hidden;
-      display: inline-block;
-      margin-bottom: 45px;
-      width: 190px;
-
-      @include peertube-button;
-      @include orange-button;
+      @include peertube-button-file(190px);
 
-      input[type=file] {
-        position: absolute;
-        top: 0;
-        right: 0;
-        min-width: 100%;
-        min-height: 100%;
-        font-size: 100px;
-        text-align: right;
-        filter: alpha(opacity=0);
-        opacity: 0;
-        outline: none;
-        background: white;
-        cursor: inherit;
-        display: block;
-      }
+      margin-bottom: 45px;
     }
   }
 }
index 4afd6160c15b540742002db46b34832d73d0f6bb..0f44d3dd7dbd50ae75e9cc04bdb89856fef0d699 100644 (file)
@@ -148,7 +148,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
                                       this.router.navigate(['/videos/list'])
                                     },
 
-                                    error => this.notificationsService.error('Error', error.text)
+                                    error => this.notificationsService.error('Error', error.message)
                                   )
       }
     )
@@ -185,7 +185,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
         error => {
           this.descriptionLoading = false
-          this.notificationsService.error('Error', error.text)
+          this.notificationsService.error('Error', error.message)
         }
       )
   }
@@ -217,7 +217,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   }
 
   getAvatarPath () {
-    return Account.GET_ACCOUNT_AVATAR_PATH(this.video.account)
+    return Account.GET_ACCOUNT_AVATAR_URL(this.video.account)
   }
 
   getVideoTags () {
@@ -247,7 +247,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
                 this.router.navigate([ '/videos/list' ])
               },
 
-              error => this.notificationsService.error('Error', error.text)
+              error => this.notificationsService.error('Error', error.message)
             )
         }
       )
index 252cf2173b30301b3a462af0b09ddbf511a50e4b..140de1b2c6f54ab2b14e8bfc8833fc308d5d97b1 100644 (file)
   @include peertube-button;
 }
 
+@mixin peertube-button-file ($width) {
+  position: relative;
+  overflow: hidden;
+  display: inline-block;
+  width: $width;
+
+  @include peertube-button;
+  @include orange-button;
+
+  input[type=file] {
+    position: absolute;
+    top: 0;
+    right: 0;
+    min-width: 100%;
+    min-height: 100%;
+    font-size: 100px;
+    text-align: right;
+    filter: alpha(opacity=0);
+    opacity: 0;
+    outline: none;
+    background: white;
+    cursor: inherit;
+    display: block;
+  }
+}
+
 @mixin avatar ($size) {
   width: $size;
   height: $size;
index 71e706346ed129e2fe289e7758f26b7d4cbef46a..e0ab3188b77921a22d179a83384391644175adf2 100644 (file)
@@ -16,17 +16,17 @@ import { VideoShareModel } from '../../models/video/video-share'
 
 const activityPubClientRouter = express.Router()
 
-activityPubClientRouter.get('/account/:name',
+activityPubClientRouter.get('/accounts/:name',
   executeIfActivityPub(asyncMiddleware(localAccountValidator)),
   executeIfActivityPub(accountController)
 )
 
-activityPubClientRouter.get('/account/:name/followers',
+activityPubClientRouter.get('/accounts/:name/followers',
   executeIfActivityPub(asyncMiddleware(localAccountValidator)),
   executeIfActivityPub(asyncMiddleware(accountFollowersController))
 )
 
-activityPubClientRouter.get('/account/:name/following',
+activityPubClientRouter.get('/accounts/:name/following',
   executeIfActivityPub(asyncMiddleware(localAccountValidator)),
   executeIfActivityPub(asyncMiddleware(accountFollowingController))
 )
index 75393ad17d9956603bbe80935bcd95c5c45c103e..57b98b84ad0b7022e45f2a55746bae71135cc646 100644 (file)
@@ -1,20 +1,26 @@
 import * as express from 'express'
+import { extname, join } from 'path'
+import * as uuidv4 from 'uuid/v4'
 import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../shared'
+import { renamePromise } from '../../helpers/core-utils'
 import { retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
-import { getFormattedObjects } from '../../helpers/utils'
-import { CONFIG } from '../../initializers'
+import { createReqFiles, getFormattedObjects } from '../../helpers/utils'
+import { AVATAR_MIMETYPE_EXT, CONFIG, sequelizeTypescript } from '../../initializers'
 import { createUserAccountAndChannel } from '../../lib/user'
 import {
   asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setPagination, setUsersSort,
   setVideosSort, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator,
   usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator
 } from '../../middlewares'
-import { videosSortValidator } from '../../middlewares/validators'
+import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators'
 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
 import { UserModel } from '../../models/account/user'
+import { AvatarModel } from '../../models/avatar/avatar'
 import { VideoModel } from '../../models/video/video'
 
+const reqAvatarFile = createReqFiles('avatarfile', CONFIG.STORAGE.AVATARS_DIR, AVATAR_MIMETYPE_EXT)
+
 const usersRouter = express.Router()
 
 usersRouter.get('/me',
@@ -71,6 +77,13 @@ usersRouter.put('/me',
   asyncMiddleware(updateMe)
 )
 
+usersRouter.post('/me/avatar/pick',
+  authenticate,
+  reqAvatarFile,
+  usersUpdateMyAvatarValidator,
+  asyncMiddleware(updateMyAvatar)
+)
+
 usersRouter.put('/:id',
   authenticate,
   ensureUserHasRight(UserRight.MANAGE_USERS),
@@ -216,6 +229,40 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
   return res.sendStatus(204)
 }
 
+async function updateMyAvatar (req: express.Request, res: express.Response, next: express.NextFunction) {
+  const avatarPhysicalFile = req.files['avatarfile'][0]
+  const actor = res.locals.oauth.token.user.Account.Actor
+
+  const avatarDir = CONFIG.STORAGE.AVATARS_DIR
+  const source = join(avatarDir, avatarPhysicalFile.filename)
+  const extension = extname(avatarPhysicalFile.filename)
+  const avatarName = uuidv4() + extension
+  const destination = join(avatarDir, avatarName)
+
+  await renamePromise(source, destination)
+
+  const { avatar } = await sequelizeTypescript.transaction(async t => {
+    const avatar = await AvatarModel.create({
+      filename: avatarName
+    }, { transaction: t })
+
+    if (actor.Avatar) {
+      await actor.Avatar.destroy({ transaction: t })
+    }
+
+    actor.set('avatarId', avatar.id)
+    await actor.save({ transaction: t })
+
+    return { actor, avatar }
+  })
+
+  return res
+    .json({
+      avatar: avatar.toFormattedJSON()
+    })
+    .end()
+}
+
 async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) {
   const body: UserUpdate = req.body
   const user = res.locals.user as UserModel
index 11e3da5cc835a768ef3eaac1d05f63e76161ac47..ff0d967e182c5b675ebfa96256be7bb33acd7916 100644 (file)
@@ -6,7 +6,7 @@ import { renamePromise } from '../../../helpers/core-utils'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { getVideoFileHeight } from '../../../helpers/ffmpeg-utils'
 import { logger } from '../../../helpers/logger'
-import { generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils'
+import { createReqFiles, generateRandomString, getFormattedObjects, getServerActor, resetSequelizeInstance } from '../../../helpers/utils'
 import {
   CONFIG, sequelizeTypescript, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT,
   VIDEO_PRIVACIES
@@ -29,28 +29,7 @@ import { rateVideoRouter } from './rate'
 
 const videosRouter = express.Router()
 
-// multer configuration
-const storage = multer.diskStorage({
-  destination: (req, file, cb) => {
-    cb(null, CONFIG.STORAGE.VIDEOS_DIR)
-  },
-
-  filename: async (req, file, cb) => {
-    const extension = VIDEO_MIMETYPE_EXT[file.mimetype]
-    let randomString = ''
-
-    try {
-      randomString = await generateRandomString(16)
-    } catch (err) {
-      logger.error('Cannot generate random string for file name.', err)
-      randomString = 'fake-random-string'
-    }
-
-    cb(null, randomString + extension)
-  }
-})
-
-const reqFiles = multer({ storage: storage }).fields([{ name: 'videofile', maxCount: 1 }])
+const reqVideoFile = createReqFiles('videofile', CONFIG.STORAGE.VIDEOS_DIR, VIDEO_MIMETYPE_EXT)
 
 videosRouter.use('/', abuseVideoRouter)
 videosRouter.use('/', blacklistRouter)
@@ -85,7 +64,7 @@ videosRouter.put('/:id',
 )
 videosRouter.post('/upload',
   authenticate,
-  reqFiles,
+  reqVideoFile,
   asyncMiddleware(videosAddValidator),
   asyncMiddleware(addVideoRetryWrapper)
 )
index ccae6051727177c18d92290a53ef3d5b0a5d637a..eece9c06b5103b39728f0db37030f137b97adfe6 100644 (file)
@@ -32,6 +32,12 @@ staticRouter.use(
   express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE })
 )
 
+const avatarsPhysicalPath = CONFIG.STORAGE.AVATARS_DIR
+staticRouter.use(
+  STATIC_PATHS.AVATARS,
+  express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE })
+)
+
 // Video previews path for express
 staticRouter.use(
   STATIC_PATHS.PREVIEWS + ':uuid.jpg',
index 159c2a7004a7152a71d6920af83e4077e4d9a219..6ed60c1c4a8da6bb7c3e07918baae9e30c4e4703 100644 (file)
@@ -1,7 +1,7 @@
 import * as validator from 'validator'
 import 'express-validator'
 
-import { exists } from './misc'
+import { exists, isArray } from './misc'
 import { CONSTRAINTS_FIELDS } from '../../initializers'
 import { UserRole } from '../../../shared'
 
@@ -37,6 +37,22 @@ function isUserRoleValid (value: any) {
   return exists(value) && validator.isInt('' + value) && UserRole[value] !== undefined
 }
 
+function isAvatarFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
+  // Should have files
+  if (!files) return false
+  if (isArray(files)) return false
+
+  // Should have videofile file
+  const avatarfile = files['avatarfile']
+  if (!avatarfile || avatarfile.length === 0) return false
+
+  // The file should exist
+  const file = avatarfile[0]
+  if (!file || !file.originalname) return false
+
+  return new RegExp('^image/(png|jpeg)$', 'i').test(file.mimetype)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -45,5 +61,6 @@ export {
   isUserVideoQuotaValid,
   isUserUsernameValid,
   isUserDisplayNSFWValid,
-  isUserAutoPlayVideoValid
+  isUserAutoPlayVideoValid,
+  isAvatarFile
 }
index 769aa83c6a0b94f19b2956bc702e4f3d1852f213..7a32e286ca56a9c5c341dd03699bfad7a37b293f 100644 (file)
@@ -1,8 +1,9 @@
 import * as express from 'express'
+import * as multer from 'multer'
 import { Model } from 'sequelize-typescript'
 import { ResultList } from '../../shared'
 import { VideoResolution } from '../../shared/models/videos'
-import { CONFIG, REMOTE_SCHEME } from '../initializers'
+import { CONFIG, REMOTE_SCHEME, VIDEO_MIMETYPE_EXT } from '../initializers'
 import { UserModel } from '../models/account/user'
 import { ActorModel } from '../models/activitypub/actor'
 import { ApplicationModel } from '../models/application/application'
@@ -26,6 +27,30 @@ function badRequest (req: express.Request, res: express.Response, next: express.
   return res.type('json').status(400).end()
 }
 
+function createReqFiles (fieldName: string, storageDir: string, mimeTypes: { [ id: string ]: string }) {
+  const storage = multer.diskStorage({
+    destination: (req, file, cb) => {
+      cb(null, storageDir)
+    },
+
+    filename: async (req, file, cb) => {
+      const extension = mimeTypes[file.mimetype]
+      let randomString = ''
+
+      try {
+        randomString = await generateRandomString(16)
+      } catch (err) {
+        logger.error('Cannot generate random string for file name.', err)
+        randomString = 'fake-random-string'
+      }
+
+      cb(null, randomString + extension)
+    }
+  })
+
+  return multer({ storage }).fields([{ name: fieldName, maxCount: 1 }])
+}
+
 async function generateRandomString (size: number) {
   const raw = await pseudoRandomBytesPromise(size)
 
@@ -122,5 +147,6 @@ export {
   resetSequelizeInstance,
   getServerActor,
   SortType,
-  getHostWithPort
+  getHostWithPort,
+  createReqFiles
 }
index 3a5a557d4c001a9afc0b43c1b1b2c0389d1ab57f..50a29dc4328468132df7a00207d4ad1473f1b03a 100644 (file)
@@ -9,7 +9,7 @@ import { isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 145
+const LAST_MIGRATION_VERSION = 150
 
 // ---------------------------------------------------------------------------
 
@@ -172,7 +172,10 @@ const CONSTRAINTS_FIELDS = {
   ACTOR: {
     PUBLIC_KEY: { min: 10, max: 5000 }, // Length
     PRIVATE_KEY: { min: 10, max: 5000 }, // Length
-    URL: { min: 3, max: 2000 } // Length
+    URL: { min: 3, max: 2000 }, // Length
+    AVATAR: {
+      EXTNAME: [ '.png', '.jpeg', '.jpg' ]
+    }
   },
   VIDEO_EVENTS: {
     COUNT: { min: 0 }
@@ -250,6 +253,12 @@ const VIDEO_MIMETYPE_EXT = {
   'video/mp4': '.mp4'
 }
 
+const AVATAR_MIMETYPE_EXT = {
+  'image/png': '.png',
+  'image/jpg': '.jpg',
+  'image/jpeg': '.jpg'
+}
+
 // ---------------------------------------------------------------------------
 
 const SERVER_ACTOR_NAME = 'peertube'
@@ -291,7 +300,8 @@ const STATIC_PATHS = {
   PREVIEWS: '/static/previews/',
   THUMBNAILS: '/static/thumbnails/',
   TORRENTS: '/static/torrents/',
-  WEBSEED: '/static/webseed/'
+  WEBSEED: '/static/webseed/',
+  AVATARS: '/static/avatars/'
 }
 
 // Cache control
@@ -376,5 +386,6 @@ export {
   VIDEO_PRIVACIES,
   VIDEO_LICENCES,
   VIDEO_RATE_TYPES,
-  VIDEO_MIMETYPE_EXT
+  VIDEO_MIMETYPE_EXT,
+  AVATAR_MIMETYPE_EXT
 }
diff --git a/server/initializers/migrations/0150-avatar-cascade.ts b/server/initializers/migrations/0150-avatar-cascade.ts
new file mode 100644 (file)
index 0000000..8216967
--- /dev/null
@@ -0,0 +1,28 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize
+}): Promise<void> {
+  await utils.queryInterface.removeConstraint('actor', 'actor_avatarId_fkey')
+
+  await utils.queryInterface.addConstraint('actor', [ 'avatarId' ], {
+    type: 'foreign key',
+    references: {
+      table: 'avatar',
+      field: 'id'
+    },
+    onDelete: 'set null',
+    onUpdate: 'CASCADE'
+  })
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index e590dc72d02069107efb1f0b41c4ec13a6c79d05..e557896e854ed273cab559e741c7c5a2624b293e 100644 (file)
@@ -1,16 +1,20 @@
 import * as Bluebird from 'bluebird'
+import { join } from 'path'
 import { Transaction } from 'sequelize'
 import * as url from 'url'
+import * as uuidv4 from 'uuid/v4'
 import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub'
 import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
 import { isRemoteActorValid } from '../../helpers/custom-validators/activitypub/actor'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
 import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
-import { doRequest } from '../../helpers/requests'
+import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests'
 import { CONFIG, sequelizeTypescript } from '../../initializers'
 import { AccountModel } from '../../models/account/account'
 import { ActorModel } from '../../models/activitypub/actor'
+import { AvatarModel } from '../../models/avatar/avatar'
 import { ServerModel } from '../../models/server/server'
 import { VideoChannelModel } from '../../models/video/video-channel'
 
@@ -62,6 +66,32 @@ async function getOrCreateActorAndServerAndModel (actorUrl: string, recurseIfNee
   return actor
 }
 
+function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) {
+  return new ActorModel({
+    type,
+    url,
+    preferredUsername,
+    uuid,
+    publicKey: null,
+    privateKey: null,
+    followersCount: 0,
+    followingCount: 0,
+    inboxUrl: url + '/inbox',
+    outboxUrl: url + '/outbox',
+    sharedInboxUrl: CONFIG.WEBSERVER.URL + '/inbox',
+    followersUrl: url + '/followers',
+    followingUrl: url + '/following'
+  })
+}
+
+export {
+  getOrCreateActorAndServerAndModel,
+  buildActorInstance,
+  setAsyncActorKeys
+}
+
+// ---------------------------------------------------------------------------
+
 function saveActorAndServerAndModelIfNotExist (
   result: FetchRemoteActorResult,
   ownerActor?: ActorModel,
@@ -90,6 +120,14 @@ function saveActorAndServerAndModelIfNotExist (
     // Save our new account in database
     actor.set('serverId', server.id)
 
+    // Avatar?
+    if (result.avatarName) {
+      const avatar = await AvatarModel.create({
+        filename: result.avatarName
+      }, { transaction: t })
+      actor.set('avatarId', avatar.id)
+    }
+
     // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists
     // (which could be false in a retried query)
     const actorCreated = await ActorModel.create(actor.toJSON(), { transaction: t })
@@ -112,6 +150,7 @@ type FetchRemoteActorResult = {
   actor: ActorModel
   name: string
   summary: string
+  avatarName?: string
   attributedTo: ActivityPubAttributedTo[]
 }
 async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResult> {
@@ -151,43 +190,33 @@ async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResu
     followingUrl: actorJSON.following
   })
 
+  // Fetch icon?
+  let avatarName: string = undefined
+  if (
+    actorJSON.icon && actorJSON.icon.type === 'Image' && actorJSON.icon.mediaType === 'image/png' &&
+    isActivityPubUrlValid(actorJSON.icon.url)
+  ) {
+    const extension = actorJSON.icon.mediaType === 'image/png' ? '.png' : '.jpg'
+
+    avatarName = uuidv4() + extension
+    const destPath = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
+
+    await doRequestAndSaveToFile({
+      method: 'GET',
+      uri: actorJSON.icon.url
+    }, destPath)
+  }
+
   const name = actorJSON.name || actorJSON.preferredUsername
   return {
     actor,
     name,
+    avatarName,
     summary: actorJSON.summary,
     attributedTo: actorJSON.attributedTo
   }
 }
 
-function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) {
-  return new ActorModel({
-    type,
-    url,
-    preferredUsername,
-    uuid,
-    publicKey: null,
-    privateKey: null,
-    followersCount: 0,
-    followingCount: 0,
-    inboxUrl: url + '/inbox',
-    outboxUrl: url + '/outbox',
-    sharedInboxUrl: CONFIG.WEBSERVER.URL + '/inbox',
-    followersUrl: url + '/followers',
-    followingUrl: url + '/following'
-  })
-}
-
-export {
-  getOrCreateActorAndServerAndModel,
-  saveActorAndServerAndModelIfNotExist,
-  fetchRemoteActor,
-  buildActorInstance,
-  setAsyncActorKeys
-}
-
-// ---------------------------------------------------------------------------
-
 async function fetchActorTotalItems (url: string) {
   const options = {
     uri: url,
index 3d5f0523cfd4bb17daeaa15ba6737838c903a74b..0d76922e043244aec73c40a929d12439713298a6 100644 (file)
@@ -18,7 +18,7 @@ function getVideoChannelActivityPubUrl (videoChannelUUID: string) {
 }
 
 function getAccountActivityPubUrl (accountName: string) {
-  return CONFIG.WEBSERVER.URL + '/account/' + accountName
+  return CONFIG.WEBSERVER.URL + '/accounts/' + accountName
 }
 
 function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) {
index db40a5c889e09cf40d1b245b7ffd06ef94dfd534..42ebddd567984d7513fec89c5aeeb8564379db8b 100644 (file)
@@ -3,12 +3,14 @@ import 'express-validator'
 import { body, param } from 'express-validator/check'
 import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
 import {
+  isAvatarFile,
   isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid,
   isUserVideoQuotaValid
 } from '../../helpers/custom-validators/users'
-import { isVideoExist } from '../../helpers/custom-validators/videos'
+import { isVideoExist, isVideoFile } from '../../helpers/custom-validators/videos'
 import { logger } from '../../helpers/logger'
 import { isSignupAllowed } from '../../helpers/utils'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
 import { UserModel } from '../../models/account/user'
 import { areValidationErrors } from './utils'
 
@@ -96,6 +98,21 @@ const usersUpdateMeValidator = [
   }
 ]
 
+const usersUpdateMyAvatarValidator = [
+  body('avatarfile').custom((value, { req }) => isAvatarFile(req.files)).withMessage(
+    'This file is not supported. Please, make sure it is of the following type : '
+    + CONSTRAINTS_FIELDS.ACTOR.AVATAR.EXTNAME.join(', ')
+  ),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking usersUpdateMyAvatarValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
 const usersGetValidator = [
   param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
 
@@ -145,7 +162,8 @@ export {
   usersUpdateMeValidator,
   usersVideoRatingValidator,
   ensureUserRegistrationAllowed,
-  usersGetValidator
+  usersGetValidator,
+  usersUpdateMyAvatarValidator
 }
 
 // ---------------------------------------------------------------------------
index 1ee232537ff635061a2bba3b28d816d8698e0061..d3503aaa385a4b55ddd5aa70a9e551368319ae42 100644 (file)
@@ -13,6 +13,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
+import { Account } from '../../../shared/models/actors'
 import { isUserUsernameValid } from '../../helpers/custom-validators/users'
 import { sendDeleteActor } from '../../lib/activitypub/send'
 import { ActorModel } from '../activitypub/actor'
@@ -165,11 +166,12 @@ export class AccountModel extends Model<AccountModel> {
     return AccountModel.findOne(query)
   }
 
-  toFormattedJSON () {
+  toFormattedJSON (): Account {
     const actor = this.Actor.toFormattedJSON()
     const account = {
       id: this.id,
-      name: this.name,
+      name: this.Actor.preferredUsername,
+      displayName: this.name,
       createdAt: this.createdAt,
       updatedAt: this.updatedAt
     }
index d7e09e3288d12d597c342fe3884d4ace152dbab5..4226bcb35a579b09d81ecf06f6fc13129a2bb962 100644 (file)
@@ -4,6 +4,7 @@ import {
   Scopes, Table, UpdatedAt
 } from 'sequelize-typescript'
 import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
+import { User } from '../../../shared/models/users'
 import {
   isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid,
   isUserVideoQuotaValid
@@ -210,7 +211,7 @@ export class UserModel extends Model<UserModel> {
     return comparePassword(password, this.password)
   }
 
-  toFormattedJSON () {
+  toFormattedJSON (): User {
     const json = {
       id: this.id,
       username: this.username,
@@ -221,11 +222,12 @@ export class UserModel extends Model<UserModel> {
       roleLabel: USER_ROLE_LABELS[ this.role ],
       videoQuota: this.videoQuota,
       createdAt: this.createdAt,
-      account: this.Account.toFormattedJSON()
+      account: this.Account.toFormattedJSON(),
+      videoChannels: []
     }
 
     if (Array.isArray(this.Account.VideoChannels) === true) {
-      json['videoChannels'] = this.Account.VideoChannels
+      json.videoChannels = this.Account.VideoChannels
         .map(c => c.toFormattedJSON())
         .sort((v1, v2) => {
           if (v1.createdAt < v2.createdAt) return -1
index 3d96b3706bc4c5690c9be56c90e206125c291b51..8422653df716c8cf3d0bef162f967ee728bcd45f 100644 (file)
@@ -1,5 +1,5 @@
 import { values } from 'lodash'
-import { join } from 'path'
+import { extname, join } from 'path'
 import * as Sequelize from 'sequelize'
 import {
   AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, DefaultScope, ForeignKey, HasMany, HasOne, Is, IsUUID, Model, Scopes,
@@ -30,6 +30,10 @@ enum ScopeNames {
     {
       model: () => ServerModel,
       required: false
+    },
+    {
+      model: () => AvatarModel,
+      required: false
     }
   ]
 })
@@ -47,6 +51,10 @@ enum ScopeNames {
       {
         model: () => ServerModel,
         required: false
+      },
+      {
+        model: () => AvatarModel,
+        required: false
       }
     ]
   }
@@ -141,7 +149,7 @@ export class ActorModel extends Model<ActorModel> {
     foreignKey: {
       allowNull: true
     },
-    onDelete: 'cascade'
+    onDelete: 'set null'
   })
   Avatar: AvatarModel
 
@@ -253,11 +261,7 @@ export class ActorModel extends Model<ActorModel> {
   toFormattedJSON () {
     let avatar: Avatar = null
     if (this.Avatar) {
-      avatar = {
-        path: join(AVATARS_DIR.ACCOUNT, this.Avatar.filename),
-        createdAt: this.Avatar.createdAt,
-        updatedAt: this.Avatar.updatedAt
-      }
+      avatar = this.Avatar.toFormattedJSON()
     }
 
     let score: number
@@ -286,6 +290,16 @@ export class ActorModel extends Model<ActorModel> {
       activityPubType = 'Group' as 'Group'
     }
 
+    let icon = undefined
+    if (this.avatarId) {
+      const extension = extname(this.Avatar.filename)
+      icon = {
+        type: 'Image',
+        mediaType: extension === '.png' ? 'image/png' : 'image/jpeg',
+        url: this.getAvatarUrl()
+      }
+    }
+
     const json = {
       type: activityPubType,
       id: this.url,
@@ -304,7 +318,8 @@ export class ActorModel extends Model<ActorModel> {
         id: this.getPublicKeyUrl(),
         owner: this.url,
         publicKeyPem: this.publicKey
-      }
+      },
+      icon
     }
 
     return activityPubContextify(json)
@@ -353,4 +368,10 @@ export class ActorModel extends Model<ActorModel> {
   getHost () {
     return this.Server ? this.Server.host : CONFIG.WEBSERVER.HOST
   }
+
+  getAvatarUrl () {
+    if (!this.avatarId) return undefined
+
+    return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath
+  }
 }
index 2e7a8ae2c1e878c6210d3574c59789fb83e496d0..7493c3d75f57dc85c509b558f7f6bf1684c392a0 100644 (file)
@@ -1,4 +1,10 @@
-import { AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { join } from 'path'
+import { AfterDestroy, AllowNull, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { Avatar } from '../../../shared/models/avatars/avatar.model'
+import { unlinkPromise } from '../../helpers/core-utils'
+import { logger } from '../../helpers/logger'
+import { CONFIG, STATIC_PATHS } from '../../initializers'
+import { sendDeleteVideo } from '../../lib/activitypub/send'
 
 @Table({
   tableName: 'avatar'
@@ -14,4 +20,26 @@ export class AvatarModel extends Model<AvatarModel> {
 
   @UpdatedAt
   updatedAt: Date
+
+  @AfterDestroy
+  static removeFilesAndSendDelete (instance: AvatarModel) {
+    return instance.removeAvatar()
+  }
+
+  toFormattedJSON (): Avatar {
+    return {
+      path: this.getWebserverPath(),
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt
+    }
+  }
+
+  getWebserverPath () {
+    return join(STATIC_PATHS.AVATARS, this.filename)
+  }
+
+  removeAvatar () {
+    const avatarPath = join(CONFIG.STORAGE.AVATARS_DIR, this.filename)
+    return unlinkPromise(avatarPath)
+  }
 }
index d381ccafa6e352248cc04296a6e8c66b8f4b30f0..829022a51c0ec9b49039d912cf4deee0ea0c30a4 100644 (file)
@@ -214,7 +214,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
 
   static listThreadCommentsForApi (videoId: number, threadId: number) {
     const query = {
-      order: [ [ 'id', 'ASC' ] ],
+      order: [ [ 'createdAt', 'DESC' ] ],
       where: {
         videoId,
         [ Sequelize.Op.or ]: [
index 0c126dbff3516f9c0c3d4dc76508a65bd885b93a..44412ad828771a6ddac814b5cb0b4d0503582fcc 100644 (file)
@@ -2,11 +2,13 @@
 
 import { omit } from 'lodash'
 import 'mocha'
+import { join } from "path"
 import { UserRole } from '../../../../shared'
 
 import {
   createUser, flushTests, getMyUserInformation, getMyUserVideoRating, getUsersList, immutableAssign, killallServers, makeGetRequest,
-  makePostBodyRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers, updateUser,
+  makePostBodyRequest, makePostUploadRequest, makePutBodyRequest, registerUser, removeUser, runServer, ServerInfo, setAccessTokensToServers,
+  updateUser,
   uploadVideo, userLogin
 } from '../../utils'
 import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params'
@@ -266,6 +268,24 @@ describe('Test users API validators', function () {
     })
   })
 
+  describe('When updating my avatar', function () {
+    it('Should fail without an incorrect input file', async function () {
+      const fields = {}
+      const attaches = {
+        'avatarfile': join(__dirname, '..', 'fixtures', 'video_short.mp4')
+      }
+      await makePostUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      const fields = {}
+      const attaches = {
+        'avatarfile': join(__dirname, '..', 'fixtures', 'avatar.png')
+      }
+      await makePostUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches })
+    })
+  })
+
   describe('When updating a user', function () {
 
     before(async function () {
diff --git a/server/tests/api/fixtures/avatar.png b/server/tests/api/fixtures/avatar.png
new file mode 100644 (file)
index 0000000..4b7fd2c
Binary files /dev/null and b/server/tests/api/fixtures/avatar.png differ
index 19549acdd43b1e30c96ff4c81dcec6aff56d23f8..3390b2d56e3b79ecbb845e5b1d25d48f100ee8ec 100644 (file)
@@ -6,7 +6,7 @@ import { UserRole } from '../../../../shared/index'
 import {
   createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoRating, getUserInformation, getUsersList,
   getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, registerUser, removeUser, removeVideo,
-  runServer, ServerInfo, serverLogin, updateMyUser, updateUser, uploadVideo
+  runServer, ServerInfo, serverLogin, testVideoImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo
 } from '../../utils/index'
 import { follow } from '../../utils/server/follows'
 import { setAccessTokensToServers } from '../../utils/users/login'
@@ -340,6 +340,22 @@ describe('Test users', function () {
     expect(user.id).to.be.a('number')
   })
 
+  it('Should be able to update my avatar', async function () {
+    const fixture = 'avatar.png'
+
+    await updateMyAvatar({
+      url: server.url,
+      accessToken: accessTokenUser,
+      fixture
+    })
+
+    const res = await getMyUserInformation(server.url, accessTokenUser)
+    const user = res.body
+
+    const test = await testVideoImage(server.url, 'avatar', user.account.avatar.path, '.png')
+    expect(test).to.equal(true)
+  })
+
   it('Should be able to update another user', async function () {
     await updateUser({
       url: server.url,
index e0cca3f5107faeb5fbc3fe22949f0ad8ce17fdf7..90b1ca0a668063336152ca4da20d8260bff66218 100644 (file)
@@ -1,5 +1,6 @@
+import { isAbsolute, join } from 'path'
 import * as request from 'supertest'
-import { makePutBodyRequest } from '../'
+import { makePostUploadRequest, makePutBodyRequest } from '../'
 
 import { UserRole } from '../../../../shared/index'
 
@@ -137,6 +138,29 @@ function updateMyUser (options: {
   })
 }
 
+function updateMyAvatar (options: {
+  url: string,
+  accessToken: string,
+  fixture: string
+}) {
+  const path = '/api/v1/users/me/avatar/pick'
+  let filePath = ''
+  if (isAbsolute(options.fixture)) {
+    filePath = options.fixture
+  } else {
+    filePath = join(__dirname, '..', '..', 'api', 'fixtures', options.fixture)
+  }
+
+  return makePostUploadRequest({
+    url: options.url,
+    path,
+    token: options.accessToken,
+    fields: {},
+    attaches: { avatarfile: filePath },
+    statusCodeExpected: 200
+  })
+}
+
 function updateUser (options: {
   url: string
   userId: number,
@@ -173,5 +197,6 @@ export {
   removeUser,
   updateUser,
   updateMyUser,
-  getUserInformation
+  getUserInformation,
+  updateMyAvatar
 }
index d6bf27dc7a8b5d10586d48c21cdb3134a6d1979d..aca51ee5d456f52a613eaf3e908f2d61893cc0de 100644 (file)
@@ -201,7 +201,7 @@ function searchVideoWithSort (url: string, search: string, sort: string) {
           .expect('Content-Type', /json/)
 }
 
-async function testVideoImage (url: string, imageName: string, imagePath: string) {
+async function testVideoImage (url: string, imageName: string, imagePath: string, extension = '.jpg') {
   // Don't test images if the node env is not set
   // Because we need a special ffmpeg version for this test
   if (process.env['NODE_TEST_IMAGE']) {
@@ -209,7 +209,7 @@ async function testVideoImage (url: string, imageName: string, imagePath: string
                         .get(imagePath)
                         .expect(200)
 
-    const data = await readFilePromise(join(__dirname, '..', '..', 'api', 'fixtures', imageName + '.jpg'))
+    const data = await readFilePromise(join(__dirname, '..', '..', 'api', 'fixtures', imageName + extension))
 
     return data.equals(res.body)
   } else {
index d9f80b94c88694ee2656813e5a87a3def8070900..78256e9be57fd1b6f63e29c21367a6205f88061f 100644 (file)
@@ -27,6 +27,10 @@ export interface ActivityPubActor {
   }
 
   // Not used
-  // icon: string[]
+  icon: {
+    type: 'Image'
+    mediaType: 'image/png'
+    url: string
+  }
   // liked: string
 }
index d1470131722a048ffd60fdbf9e51ee619ec9bf27..ef6fca53919c847a67cf334dd511669a348097ba 100644 (file)
@@ -4,6 +4,7 @@ export interface Account {
   id: number
   uuid: string
   name: string
+  displayName: string
   host: string
   followingCount: number
   followersCount: number