Send account activitypub update events
authorChocobozzz <me@florianbigard.com>
Wed, 3 Jan 2018 15:38:50 +0000 (16:38 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 3 Jan 2018 15:38:50 +0000 (16:38 +0100)
23 files changed:
server/controllers/api/accounts.ts [new file with mode: 0644]
server/controllers/api/index.ts
server/controllers/api/users.ts
server/helpers/custom-validators/activitypub/activity.ts
server/helpers/custom-validators/activitypub/actor.ts
server/initializers/constants.ts
server/lib/activitypub/actor.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/send/send-update.ts
server/middlewares/sort.ts
server/middlewares/validators/account.ts
server/middlewares/validators/sort.ts
server/models/account/account.ts
server/models/activitypub/actor.ts
server/models/video/video-share.ts
server/tests/api/check-params/accounts.ts [new file with mode: 0644]
server/tests/api/check-params/index.ts
server/tests/api/fixtures/avatar2-resized.png [new file with mode: 0644]
server/tests/api/fixtures/avatar2.png [new file with mode: 0644]
server/tests/api/index-slow.ts
server/tests/api/users/users-multiple-servers.ts [new file with mode: 0644]
server/tests/utils/users/accounts.ts [new file with mode: 0644]
shared/models/activitypub/activity.ts

diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
new file mode 100644 (file)
index 0000000..aded581
--- /dev/null
@@ -0,0 +1,38 @@
+import * as express from 'express'
+import { getFormattedObjects } from '../../helpers/utils'
+import { asyncMiddleware, paginationValidator, setAccountsSort, setPagination } from '../../middlewares'
+import { accountsGetValidator, accountsSortValidator } from '../../middlewares/validators'
+import { AccountModel } from '../../models/account/account'
+
+const accountsRouter = express.Router()
+
+accountsRouter.get('/',
+  paginationValidator,
+  accountsSortValidator,
+  setAccountsSort,
+  setPagination,
+  asyncMiddleware(listAccounts)
+)
+
+accountsRouter.get('/:id',
+  asyncMiddleware(accountsGetValidator),
+  getAccount
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  accountsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) {
+  return res.json(res.locals.account.toFormattedJSON())
+}
+
+async function listAccounts (req: express.Request, res: express.Response, next: express.NextFunction) {
+  const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
index 1fd44ac11afd4f496cb1fda24418e213856a98ce..3b499f3b7f3180412c105cbe092b4b5b51e79499 100644 (file)
@@ -5,6 +5,7 @@ import { jobsRouter } from './jobs'
 import { oauthClientsRouter } from './oauth-clients'
 import { serverRouter } from './server'
 import { usersRouter } from './users'
+import { accountsRouter } from './accounts'
 import { videosRouter } from './videos'
 
 const apiRouter = express.Router()
@@ -13,6 +14,7 @@ apiRouter.use('/server', serverRouter)
 apiRouter.use('/oauth-clients', oauthClientsRouter)
 apiRouter.use('/config', configRouter)
 apiRouter.use('/users', usersRouter)
+apiRouter.use('/accounts', accountsRouter)
 apiRouter.use('/videos', videosRouter)
 apiRouter.use('/jobs', jobsRouter)
 apiRouter.use('/ping', pong)
index d37813595294e77c592c1cf454a511c997834bea..ef2b63f51fad3c85fc444d48896b76df238d4c4b 100644 (file)
@@ -8,6 +8,7 @@ import { retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
 import { createReqFiles, getFormattedObjects } from '../../helpers/utils'
 import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers'
+import { sendUpdateUser } from '../../lib/activitypub/send'
 import { createUserAccountAndChannel } from '../../lib/user'
 import {
   asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setPagination, setUsersSort,
@@ -217,7 +218,6 @@ async function removeUser (req: express.Request, res: express.Response, next: ex
 async function updateMe (req: express.Request, res: express.Response, next: express.NextFunction) {
   const body: UserUpdateMe = req.body
 
-  // FIXME: user is not already a Sequelize instance?
   const user = res.locals.oauth.token.user
 
   if (body.password !== undefined) user.password = body.password
@@ -226,13 +226,15 @@ async function updateMe (req: express.Request, res: express.Response, next: expr
   if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
 
   await user.save()
+  await sendUpdateUser(user, undefined)
 
   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 user = res.locals.oauth.token.user
+  const actor = user.Account.Actor
 
   const avatarDir = CONFIG.STORAGE.AVATARS_DIR
   const source = join(avatarDir, avatarPhysicalFile.filename)
@@ -252,12 +254,19 @@ async function updateMyAvatar (req: express.Request, res: express.Response, next
     }, { transaction: t })
 
     if (actor.Avatar) {
-      await actor.Avatar.destroy({ transaction: t })
+      try {
+        await actor.Avatar.destroy({ transaction: t })
+      } catch (err) {
+        logger.error('Cannot remove old avatar of user %s.', user.username, err)
+      }
     }
 
     actor.set('avatarId', avatar.id)
+    actor.Avatar = avatar
     await actor.save({ transaction: t })
 
+    await sendUpdateUser(user, undefined)
+
     return { actor, avatar }
   })
 
@@ -278,6 +287,8 @@ async function updateUser (req: express.Request, res: express.Response, next: ex
 
   await user.save()
 
+  // Don't need to send this update to followers, these attributes are not propagated
+
   return res.sendStatus(204)
 }
 
index fbdde10ad51bbac7089a313f9f17ba07bd155140..856c87f2c3eb8a8fa50b25892de1096f606b14a1 100644 (file)
@@ -1,6 +1,6 @@
 import * as validator from 'validator'
 import { Activity, ActivityType } from '../../../../shared/models/activitypub'
-import { isActorAcceptActivityValid, isActorDeleteActivityValid, isActorFollowActivityValid } from './actor'
+import { isActorAcceptActivityValid, isActorDeleteActivityValid, isActorFollowActivityValid, isActorUpdateActivityValid } from './actor'
 import { isAnnounceActivityValid } from './announce'
 import { isActivityPubUrlValid } from './misc'
 import { isDislikeActivityValid, isLikeActivityValid } from './rate'
@@ -64,7 +64,8 @@ function checkCreateActivity (activity: any) {
 }
 
 function checkUpdateActivity (activity: any) {
-  return isVideoTorrentUpdateActivityValid(activity)
+  return isVideoTorrentUpdateActivityValid(activity) ||
+    isActorUpdateActivityValid(activity)
 }
 
 function checkDeleteActivity (activity: any) {
index 700e060070472984b6b4030109df41179db05672..8820bb2a453e326aebabdb9d09f5647f9be82eb2 100644 (file)
@@ -45,22 +45,22 @@ function isActorPrivateKeyValid (privateKey: string) {
     validator.isLength(privateKey, CONSTRAINTS_FIELDS.ACTORS.PRIVATE_KEY)
 }
 
-function isRemoteActorValid (remoteActor: any) {
-  return exists(remoteActor) &&
-    isActivityPubUrlValid(remoteActor.id) &&
-    isActorTypeValid(remoteActor.type) &&
-    isActivityPubUrlValid(remoteActor.following) &&
-    isActivityPubUrlValid(remoteActor.followers) &&
-    isActivityPubUrlValid(remoteActor.inbox) &&
-    isActivityPubUrlValid(remoteActor.outbox) &&
-    isActorPreferredUsernameValid(remoteActor.preferredUsername) &&
-    isActivityPubUrlValid(remoteActor.url) &&
-    isActorPublicKeyObjectValid(remoteActor.publicKey) &&
-    isActorEndpointsObjectValid(remoteActor.endpoints) &&
-    setValidAttributedTo(remoteActor) &&
+function isActorObjectValid (actor: any) {
+  return exists(actor) &&
+    isActivityPubUrlValid(actor.id) &&
+    isActorTypeValid(actor.type) &&
+    isActivityPubUrlValid(actor.following) &&
+    isActivityPubUrlValid(actor.followers) &&
+    isActivityPubUrlValid(actor.inbox) &&
+    isActivityPubUrlValid(actor.outbox) &&
+    isActorPreferredUsernameValid(actor.preferredUsername) &&
+    isActivityPubUrlValid(actor.url) &&
+    isActorPublicKeyObjectValid(actor.publicKey) &&
+    isActorEndpointsObjectValid(actor.endpoints) &&
+    setValidAttributedTo(actor) &&
     // If this is not an account, it should be attributed to an account
     // In PeerTube we use this to attach a video channel to a specific account
-    (remoteActor.type === 'Person' || remoteActor.attributedTo.length !== 0)
+    (actor.type === 'Person' || actor.attributedTo.length !== 0)
 }
 
 function isActorFollowingCountValid (value: string) {
@@ -84,6 +84,11 @@ function isActorAcceptActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Accept')
 }
 
+function isActorUpdateActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Update') &&
+    isActorObjectValid(activity.object)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -93,11 +98,11 @@ export {
   isActorPublicKeyValid,
   isActorPreferredUsernameValid,
   isActorPrivateKeyValid,
-  isRemoteActorValid,
+  isActorObjectValid,
   isActorFollowingCountValid,
   isActorFollowersCountValid,
   isActorFollowActivityValid,
   isActorAcceptActivityValid,
   isActorDeleteActivityValid,
-  isActorNameValid
+  isActorUpdateActivityValid
 }
index d9b21b3895ebc937b958d2b372e8a06cf9eb59b6..d2bcea44379aa21db2ae29d215eee036476da6de 100644 (file)
@@ -22,6 +22,7 @@ const PAGINATION_COUNT_DEFAULT = 15
 // Sortable columns per schema
 const SORTABLE_COLUMNS = {
   USERS: [ 'id', 'username', 'createdAt' ],
+  ACCOUNTS: [ 'createdAt' ],
   JOBS: [ 'id', 'createdAt' ],
   VIDEO_ABUSES: [ 'id', 'createdAt' ],
   VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
index e557896e854ed273cab559e741c7c5a2624b293e..b6ba2cc22de65925808851e91efd0eeec60ed09f 100644 (file)
@@ -5,13 +5,13 @@ 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 { isActorObjectValid } 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, doRequestAndSaveToFile } from '../../helpers/requests'
-import { CONFIG, sequelizeTypescript } from '../../initializers'
+import { AVATAR_MIMETYPE_EXT, CONFIG, sequelizeTypescript } from '../../initializers'
 import { AccountModel } from '../../models/account/account'
 import { ActorModel } from '../../models/activitypub/actor'
 import { AvatarModel } from '../../models/avatar/avatar'
@@ -84,10 +84,52 @@ function buildActorInstance (type: ActivityPubActorType, url: string, preferredU
   })
 }
 
+async function fetchActorTotalItems (url: string) {
+  const options = {
+    uri: url,
+    method: 'GET',
+    json: true,
+    activityPub: true
+  }
+
+  let requestResult
+  try {
+    requestResult = await doRequest(options)
+  } catch (err) {
+    logger.warn('Cannot fetch remote actor count %s.', url, err)
+    return undefined
+  }
+
+  return requestResult.totalItems ? requestResult.totalItems : 0
+}
+
+async function fetchAvatarIfExists (actorJSON: ActivityPubActor) {
+  if (
+    actorJSON.icon && actorJSON.icon.type === 'Image' && AVATAR_MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined &&
+    isActivityPubUrlValid(actorJSON.icon.url)
+  ) {
+    const extension = AVATAR_MIMETYPE_EXT[actorJSON.icon.mediaType]
+
+    const avatarName = uuidv4() + extension
+    const destPath = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
+
+    await doRequestAndSaveToFile({
+      method: 'GET',
+      uri: actorJSON.icon.url
+    }, destPath)
+
+    return avatarName
+  }
+
+  return undefined
+}
+
 export {
   getOrCreateActorAndServerAndModel,
   buildActorInstance,
-  setAsyncActorKeys
+  setAsyncActorKeys,
+  fetchActorTotalItems,
+  fetchAvatarIfExists
 }
 
 // ---------------------------------------------------------------------------
@@ -166,7 +208,7 @@ async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResu
   const requestResult = await doRequest(options)
   const actorJSON: ActivityPubActor = requestResult.body
 
-  if (isRemoteActorValid(actorJSON) === false) {
+  if (isActorObjectValid(actorJSON) === false) {
     logger.debug('Remote actor JSON is not valid.', { actorJSON: actorJSON })
     return undefined
   }
@@ -190,22 +232,7 @@ 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 avatarName = await fetchAvatarIfExists(actorJSON)
 
   const name = actorJSON.name || actorJSON.preferredUsername
   return {
@@ -217,25 +244,6 @@ async function fetchRemoteActor (actorUrl: string): Promise<FetchRemoteActorResu
   }
 }
 
-async function fetchActorTotalItems (url: string) {
-  const options = {
-    uri: url,
-    method: 'GET',
-    json: true,
-    activityPub: true
-  }
-
-  let requestResult
-  try {
-    requestResult = await doRequest(options)
-  } catch (err) {
-    logger.warn('Cannot fetch remote actor count %s.', url, err)
-    return undefined
-  }
-
-  return requestResult.totalItems ? requestResult.totalItems : 0
-}
-
 function saveAccount (actor: ActorModel, result: FetchRemoteActorResult, t: Transaction) {
   const account = new AccountModel({
     name: result.name,
index bc8ae5cc612ff25842a26c2ae3a3b24982990f78..05ea7d2728f46e26853ca1a12b235579cc3e6e81 100644 (file)
@@ -1,14 +1,18 @@
 import * as Bluebird from 'bluebird'
 import { ActivityUpdate } from '../../../../shared/models/activitypub'
+import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor'
+import { VideoTorrentObject } from '../../../../shared/models/activitypub/objects'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { logger } from '../../../helpers/logger'
 import { resetSequelizeInstance } from '../../../helpers/utils'
 import { sequelizeTypescript } from '../../../initializers'
+import { AccountModel } from '../../../models/account/account'
 import { ActorModel } from '../../../models/activitypub/actor'
+import { AvatarModel } from '../../../models/avatar/avatar'
 import { TagModel } from '../../../models/video/tag'
 import { VideoModel } from '../../../models/video/video'
 import { VideoFileModel } from '../../../models/video/video-file'
-import { getOrCreateActorAndServerAndModel } from '../actor'
+import { fetchActorTotalItems, fetchAvatarIfExists, getOrCreateActorAndServerAndModel } from '../actor'
 import { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes } from './misc'
 
 async function processUpdateActivity (activity: ActivityUpdate) {
@@ -16,6 +20,8 @@ async function processUpdateActivity (activity: ActivityUpdate) {
 
   if (activity.object.type === 'Video') {
     return processUpdateVideo(actor, activity)
+  } else if (activity.object.type === 'Person') {
+    return processUpdateAccount(actor, activity)
   }
 
   return
@@ -39,11 +45,11 @@ function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate) {
 }
 
 async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) {
-  const videoAttributesToUpdate = activity.object
+  const videoAttributesToUpdate = activity.object as VideoTorrentObject
 
   logger.debug('Updating remote video "%s".', videoAttributesToUpdate.uuid)
   let videoInstance: VideoModel
-  let videoFieldsSave: object
+  let videoFieldsSave: any
 
   try {
     await sequelizeTypescript.transaction(async t => {
@@ -54,6 +60,8 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) {
       const videoInstance = await VideoModel.loadByUrlAndPopulateAccount(videoAttributesToUpdate.id, t)
       if (!videoInstance) throw new Error('Video ' + videoAttributesToUpdate.id + ' not found.')
 
+      videoFieldsSave = videoInstance.toJSON()
+
       const videoChannel = videoInstance.VideoChannel
       if (videoChannel.Account.Actor.id !== actor.id) {
         throw new Error('Account ' + actor.url + ' does not own video channel ' + videoChannel.Actor.url)
@@ -102,3 +110,83 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) {
     throw err
   }
 }
+
+function processUpdateAccount (actor: ActorModel, activity: ActivityUpdate) {
+  const options = {
+    arguments: [ actor, activity ],
+    errorMessage: 'Cannot update the remote account with many retries'
+  }
+
+  return retryTransactionWrapper(updateRemoteAccount, options)
+}
+
+async function updateRemoteAccount (actor: ActorModel, activity: ActivityUpdate) {
+  const accountAttributesToUpdate = activity.object as ActivityPubActor
+
+  logger.debug('Updating remote account "%s".', accountAttributesToUpdate.uuid)
+  let actorInstance: ActorModel
+  let accountInstance: AccountModel
+  let actorFieldsSave: object
+  let accountFieldsSave: object
+
+  // Fetch icon?
+  const avatarName = await fetchAvatarIfExists(accountAttributesToUpdate)
+
+  try {
+    await sequelizeTypescript.transaction(async t => {
+      actorInstance = await ActorModel.loadByUrl(accountAttributesToUpdate.id, t)
+      if (!actorInstance) throw new Error('Actor ' + accountAttributesToUpdate.id + ' not found.')
+
+      actorFieldsSave = actorInstance.toJSON()
+      accountInstance = actorInstance.Account
+      accountFieldsSave = actorInstance.Account.toJSON()
+
+      const followersCount = await fetchActorTotalItems(accountAttributesToUpdate.followers)
+      const followingCount = await fetchActorTotalItems(accountAttributesToUpdate.following)
+
+      actorInstance.set('type', accountAttributesToUpdate.type)
+      actorInstance.set('uuid', accountAttributesToUpdate.uuid)
+      actorInstance.set('preferredUsername', accountAttributesToUpdate.preferredUsername)
+      actorInstance.set('url', accountAttributesToUpdate.id)
+      actorInstance.set('publicKey', accountAttributesToUpdate.publicKey.publicKeyPem)
+      actorInstance.set('followersCount', followersCount)
+      actorInstance.set('followingCount', followingCount)
+      actorInstance.set('inboxUrl', accountAttributesToUpdate.inbox)
+      actorInstance.set('outboxUrl', accountAttributesToUpdate.outbox)
+      actorInstance.set('sharedInboxUrl', accountAttributesToUpdate.endpoints.sharedInbox)
+      actorInstance.set('followersUrl', accountAttributesToUpdate.followers)
+      actorInstance.set('followingUrl', accountAttributesToUpdate.following)
+
+      if (avatarName !== undefined) {
+        if (actorInstance.avatarId) {
+          await actorInstance.Avatar.destroy({ transaction: t })
+        }
+
+        const avatar = await AvatarModel.create({
+          filename: avatarName
+        }, { transaction: t })
+
+        actor.set('avatarId', avatar.id)
+      }
+
+      await actor.save({ transaction: t })
+
+      actor.Account.set('name', accountAttributesToUpdate.name || accountAttributesToUpdate.preferredUsername)
+      await actor.Account.save({ transaction: t })
+    })
+
+    logger.info('Remote account with uuid %s updated', accountAttributesToUpdate.uuid)
+  } catch (err) {
+    if (actorInstance !== undefined && actorFieldsSave !== undefined) {
+      resetSequelizeInstance(actorInstance, actorFieldsSave)
+    }
+
+    if (accountInstance !== undefined && accountFieldsSave !== undefined) {
+      resetSequelizeInstance(accountInstance, accountFieldsSave)
+    }
+
+    // This is just a debug because we will retry the insert
+    logger.debug('Cannot update the remote account.', err)
+    throw err
+  }
+}
index b623fec6c22bd76a69a8a17d598a18bee4ca49bb..e8f11edd0ec2a21b32bc2e0498774acb8d61ace8 100644 (file)
@@ -1,6 +1,7 @@
 import { Transaction } from 'sequelize'
 import { ActivityAudience, ActivityUpdate } from '../../../../shared/models/activitypub'
 import { VideoPrivacy } from '../../../../shared/models/videos'
+import { UserModel } from '../../../models/account/user'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoModel } from '../../../models/video/video'
 import { VideoShareModel } from '../../../models/video/video-share'
@@ -22,9 +23,24 @@ async function sendUpdateVideo (video: VideoModel, t: Transaction) {
   return broadcastToFollowers(data, byActor, actorsInvolved, t)
 }
 
+async function sendUpdateUser (user: UserModel, t: Transaction) {
+  const byActor = user.Account.Actor
+
+  const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
+  const accountObject = user.Account.toActivityPubObject()
+  const audience = await getAudience(byActor, t)
+  const data = await updateActivityData(url, byActor, accountObject, t, audience)
+
+  const actorsInvolved = await VideoShareModel.loadActorsByVideoOwner(byActor.id, t)
+  actorsInvolved.push(byActor)
+
+  return broadcastToFollowers(data, byActor, actorsInvolved, t)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  sendUpdateUser,
   sendUpdateVideo
 }
 
index fdd6d419fb87f43495bff0e8bebb3da89f0f0915..4f524b49a613ec7e74be428bee21365a32260e71 100644 (file)
@@ -2,6 +2,12 @@ import * as express from 'express'
 import 'express-validator'
 import { SortType } from '../helpers/utils'
 
+function setAccountsSort (req: express.Request, res: express.Response, next: express.NextFunction) {
+  if (!req.query.sort) req.query.sort = '-createdAt'
+
+  return next()
+}
+
 function setUsersSort (req: express.Request, res: express.Response, next: express.NextFunction) {
   if (!req.query.sort) req.query.sort = '-createdAt'
 
@@ -82,5 +88,6 @@ export {
   setFollowersSort,
   setFollowingSort,
   setJobsSort,
-  setVideoCommentThreadsSort
+  setVideoCommentThreadsSort,
+  setAccountsSort
 }
index 3573a9a5053a970a9c8abdeb1d75df51e0cd4ee1..ebc2fcf2d89099c72e3effb1f36046a7301fc352 100644 (file)
@@ -1,6 +1,7 @@
 import * as express from 'express'
 import { param } from 'express-validator/check'
-import { isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts'
+import { isAccountIdExist, isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts'
+import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
 
@@ -17,8 +18,22 @@ const localAccountValidator = [
   }
 ]
 
+const accountsGetValidator = [
+  param('id').custom(isIdOrUUIDValid).withMessage('Should have a valid id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking accountsGetValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+    if (!await isAccountIdExist(req.params.id, res)) return
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
-  localAccountValidator
+  localAccountValidator,
+  accountsGetValidator
 }
index e1d8d7d1b681f1ea0a97e31a4c9d2fa5bea5812a..72c6b34e30e1f79bb69da995d276fc13369ce8ad 100644 (file)
@@ -6,6 +6,7 @@ import { areValidationErrors } from './utils'
 
 // Initialize constants here for better performances
 const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS)
+const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS)
 const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
 const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
 const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
@@ -16,6 +17,7 @@ const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOW
 const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING)
 
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
+const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
 const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
 const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
 const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
@@ -33,6 +35,7 @@ export {
   videoChannelsSortValidator,
   videosSortValidator,
   blacklistSortValidator,
+  accountsSortValidator,
   followersSortValidator,
   followingSortValidator,
   jobsSortValidator,
index d3503aaa385a4b55ddd5aa70a9e551368319ae42..4930681279e1424066c8910b7ac64fd5052b11fb 100644 (file)
@@ -18,8 +18,9 @@ import { isUserUsernameValid } from '../../helpers/custom-validators/users'
 import { sendDeleteActor } from '../../lib/activitypub/send'
 import { ActorModel } from '../activitypub/actor'
 import { ApplicationModel } from '../application/application'
+import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { throwIfNotValid } from '../utils'
+import { getSort, throwIfNotValid } from '../utils'
 import { VideoChannelModel } from '../video/video-channel'
 import { UserModel } from './user'
 
@@ -32,6 +33,10 @@ import { UserModel } from './user'
         {
           model: () => ServerModel,
           required: false
+        },
+        {
+          model: () => AvatarModel,
+          required: false
         }
       ]
     }
@@ -166,6 +171,22 @@ export class AccountModel extends Model<AccountModel> {
     return AccountModel.findOne(query)
   }
 
+  static listForApi (start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: [ getSort(sort) ]
+    }
+
+    return AccountModel.findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return {
+          data: rows,
+          total: count
+        }
+      })
+  }
+
   toFormattedJSON (): Account {
     const actor = this.Actor.toFormattedJSON()
     const account = {
index ff5ab2e327a9fcdc1c256cb9715789b0a8397337..2ef7c77a2a447d374d485f94ffa600d7ebdd7ade 100644 (file)
@@ -372,6 +372,6 @@ export class ActorModel extends Model<ActorModel> {
   getAvatarUrl () {
     if (!this.avatarId) return undefined
 
-    return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath
+    return CONFIG.WEBSERVER.URL + this.Avatar.getWebserverPath()
   }
 }
index c252fd64696bcc2714b29470a8dcbf46ba571ec3..56576f98c8efa3dafcf9c1889335e9f3c31343e2 100644 (file)
@@ -1,7 +1,9 @@
 import * as Sequelize from 'sequelize'
 import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { AccountModel } from '../account/account'
 import { ActorModel } from '../activitypub/actor'
 import { VideoModel } from './video'
+import { VideoChannelModel } from './video-channel'
 
 enum ScopeNames {
   FULL = 'FULL',
@@ -99,4 +101,42 @@ export class VideoShareModel extends Model<VideoShareModel> {
     return VideoShareModel.scope(ScopeNames.FULL).findAll(query)
       .then(res => res.map(r => r.Actor))
   }
+
+  static loadActorsByVideoOwner (actorOwnerId: number, t: Sequelize.Transaction) {
+    const query = {
+      attributes: [],
+      include: [
+        {
+          model: ActorModel,
+          required: true
+        },
+        {
+          attributes: [],
+          model: VideoModel,
+          required: true,
+          include: [
+            {
+              attributes: [],
+              model: VideoChannelModel.unscoped(),
+              required: true,
+              include: [
+                {
+                  attributes: [],
+                  model: AccountModel.unscoped(),
+                  required: true,
+                  where: {
+                    actorId: actorOwnerId
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      transaction: t
+    }
+
+    return VideoShareModel.scope(ScopeNames.FULL).findAll(query)
+      .then(res => res.map(r => r.Actor))
+  }
 }
diff --git a/server/tests/api/check-params/accounts.ts b/server/tests/api/check-params/accounts.ts
new file mode 100644 (file)
index 0000000..3512287
--- /dev/null
@@ -0,0 +1,51 @@
+/* tslint:disable:no-unused-expression */
+
+import 'mocha'
+
+import { flushTests, killallServers, runServer, ServerInfo } from '../../utils'
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params'
+import { getAccount } from '../../utils/users/accounts'
+
+describe('Test users API validators', function () {
+  const path = '/api/v1/accounts/'
+  let server: ServerInfo
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(20000)
+
+    await flushTests()
+
+    server = await runServer(1)
+  })
+
+  describe('When listing accounts', function () {
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(server.url, path, server.accessToken)
+    })
+  })
+
+  describe('When getting an account', function () {
+    it('Should return 404 with a non existing id', async function () {
+      await getAccount(server.url, 4545454, 404)
+    })
+  })
+
+  after(async function () {
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index ab0aa158012d88d29603fcdf5e69c1710294920a..4c3b372f563a37f664bf8544064f99f899747014 100644 (file)
@@ -1,4 +1,5 @@
 // Order of the tests we want to execute
+import './accounts'
 import './follows'
 import './jobs'
 import './services'
diff --git a/server/tests/api/fixtures/avatar2-resized.png b/server/tests/api/fixtures/avatar2-resized.png
new file mode 100644 (file)
index 0000000..a2e2613
Binary files /dev/null and b/server/tests/api/fixtures/avatar2-resized.png differ
diff --git a/server/tests/api/fixtures/avatar2.png b/server/tests/api/fixtures/avatar2.png
new file mode 100644 (file)
index 0000000..dae7021
Binary files /dev/null and b/server/tests/api/fixtures/avatar2.png differ
index 23b6526c7de43fa130e11542161123b47a3b2f12..fe86fc01859fc617cd90a817afd0a058919190f1 100644 (file)
@@ -5,3 +5,4 @@ import './videos/multiple-servers'
 import './server/follows'
 import './server/jobs'
 import './videos/video-comments'
+import './users/users-multiple-servers'
diff --git a/server/tests/api/users/users-multiple-servers.ts b/server/tests/api/users/users-multiple-servers.ts
new file mode 100644 (file)
index 0000000..1c7f011
--- /dev/null
@@ -0,0 +1,85 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { Account } from '../../../../shared/models/actors'
+import { doubleFollow, flushAndRunMultipleServers, wait } from '../../utils'
+import {
+  flushTests, getMyUserInformation, killallServers, ServerInfo, testVideoImage, updateMyAvatar,
+  uploadVideo
+} from '../../utils/index'
+import { getAccount, getAccountsList } from '../../utils/users/accounts'
+import { setAccessTokensToServers } from '../../utils/users/login'
+
+const expect = chai.expect
+
+describe('Test users with multiple servers', function () {
+  let servers: ServerInfo[] = []
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await flushAndRunMultipleServers(3)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+
+    // Server 1 and server 2 follow each other
+    await doubleFollow(servers[0], servers[1])
+    // Server 1 and server 3 follow each other
+    await doubleFollow(servers[0], servers[2])
+    // Server 2 and server 3 follow each other
+    await doubleFollow(servers[1], servers[2])
+
+    // The root user of server 1 is propagated to servers 2 and 3
+    await uploadVideo(servers[0].url, servers[0].accessToken, {})
+
+    await wait(5000)
+  })
+
+  it('Should be able to update my avatar', async function () {
+    this.timeout(10000)
+
+    const fixture = 'avatar2.png'
+
+    await updateMyAvatar({
+      url: servers[0].url,
+      accessToken: servers[0].accessToken,
+      fixture
+    })
+
+    const res = await getMyUserInformation(servers[0].url, servers[0].accessToken)
+    const user = res.body
+
+    const test = await testVideoImage(servers[0].url, 'avatar2-resized', user.account.avatar.path, '.png')
+    expect(test).to.equal(true)
+
+    await wait(5000)
+  })
+
+  it('Should have updated my avatar on other servers too', async function () {
+    for (const server of servers) {
+      const resAccounts = await getAccountsList(server.url, '-createdAt')
+
+      const rootServer1List = resAccounts.body.data.find(a => a.name === 'root' && a.host === 'localhost:9001') as Account
+      expect(rootServer1List).not.to.be.undefined
+
+      const resAccount = await getAccount(server.url, rootServer1List.id)
+      const rootServer1Get = resAccount.body as Account
+      expect(rootServer1Get.name).to.equal('root')
+      expect(rootServer1Get.host).to.equal('localhost:9001')
+
+      const test = await testVideoImage(server.url, 'avatar2-resized', rootServer1Get.avatar.path, '.png')
+      expect(test).to.equal(true)
+    }
+  })
+
+  after(async function () {
+    killallServers(servers)
+
+    // Keep the logs if the test failed
+    if (this[ 'ok' ]) {
+      await flushTests()
+    }
+  })
+})
diff --git a/server/tests/utils/users/accounts.ts b/server/tests/utils/users/accounts.ts
new file mode 100644 (file)
index 0000000..7171210
--- /dev/null
@@ -0,0 +1,29 @@
+import { makeGetRequest } from '../requests/requests'
+
+function getAccountsList (url: string, sort = '-createdAt', statusCodeExpected = 200) {
+  const path = '/api/v1/accounts'
+
+  return makeGetRequest({
+    url,
+    query: { sort },
+    path,
+    statusCodeExpected
+  })
+}
+
+function getAccount (url: string, accountId: number | string, statusCodeExpected = 200) {
+  const path = '/api/v1/accounts/' + accountId
+
+  return makeGetRequest({
+    url,
+    path,
+    statusCodeExpected
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  getAccount,
+  getAccountsList
+}
index 48b52d2cbe70b124aaa5be6d0e23682419b98337..a87afc54803b59e771375df5360714d38bf08fe7 100644 (file)
@@ -1,3 +1,4 @@
+import { ActivityPubActor } from './activitypub-actor'
 import { ActivityPubSignature } from './activitypub-signature'
 import { VideoTorrentObject } from './objects'
 import { DislikeObject } from './objects/dislike-object'
@@ -33,7 +34,7 @@ export interface ActivityCreate extends BaseActivity {
 
 export interface ActivityUpdate extends BaseActivity {
   type: 'Update'
-  object: VideoTorrentObject
+  object: VideoTorrentObject | ActivityPubActor
 }
 
 export interface ActivityDelete extends BaseActivity {