Browse Source

Follow works

Chocobozzz 6 years ago
parent
commit
350e31d6b6
39 changed files with 431 additions and 169 deletions
  1. 4 4
      client/src/app/+admin/friends/friend-list/friend-list.component.html
  2. 1 1
      client/src/app/+admin/friends/friend-list/friend-list.component.ts
  3. 2 2
      client/src/app/+admin/friends/shared/friend.service.ts
  4. 4 1
      server.ts
  5. 2 2
      server/controllers/activitypub/client.ts
  6. 3 1
      server/controllers/activitypub/inbox.ts
  7. 5 5
      server/controllers/activitypub/index.ts
  8. 51 17
      server/controllers/api/pods.ts
  9. 2 0
      server/controllers/index.ts
  10. 39 0
      server/controllers/webfinger.ts
  11. 19 10
      server/helpers/activitypub.ts
  12. 7 7
      server/helpers/custom-validators/accounts.ts
  13. 21 10
      server/helpers/custom-validators/activitypub/account.ts
  14. 14 5
      server/helpers/custom-validators/activitypub/activity.ts
  15. 6 4
      server/helpers/custom-validators/activitypub/misc.ts
  16. 12 2
      server/helpers/custom-validators/activitypub/videos.ts
  17. 2 1
      server/helpers/custom-validators/index.ts
  18. 25 0
      server/helpers/custom-validators/webfinger.ts
  19. 12 9
      server/helpers/webfinger.ts
  20. 11 3
      server/initializers/checker.ts
  21. 5 0
      server/initializers/constants.ts
  22. 7 3
      server/initializers/installer.ts
  23. 1 1
      server/lib/activitypub/process-add.ts
  24. 11 7
      server/lib/activitypub/process-follow.ts
  25. 19 19
      server/lib/activitypub/send-request.ts
  26. 1 0
      server/lib/jobs/http-request-job-scheduler/http-request-broadcast-handler.ts
  27. 1 0
      server/lib/jobs/http-request-job-scheduler/http-request-unicast-handler.ts
  28. 9 4
      server/lib/jobs/job-scheduler.ts
  29. 17 15
      server/middlewares/activitypub.ts
  30. 4 6
      server/middlewares/validators/account.ts
  31. 3 4
      server/middlewares/validators/activitypub/activity.ts
  32. 1 0
      server/middlewares/validators/index.ts
  33. 42 0
      server/middlewares/validators/webfinger.ts
  34. 9 6
      server/models/account/account-follow.ts
  35. 4 2
      server/models/account/account-interface.ts
  36. 34 10
      server/models/account/account.ts
  37. 6 3
      server/models/application/application-interface.ts
  38. 10 1
      server/models/application/application.ts
  39. 5 4
      server/models/job/job.ts

+ 4 - 4
client/src/app/+admin/friends/friend-list/friend-list.component.html

@@ -4,12 +4,12 @@
 
     <p-dataTable
         [value]="friends" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
-        sortField="id" (onLazyLoad)="loadLazy($event)"
+        sortField="createdAt" (onLazyLoad)="loadLazy($event)"
     >
-      <p-column field="id" header="ID" [sortable]="true"></p-column>
-      <p-column field="host" header="Host" [sortable]="true"></p-column>
+      <p-column field="id" header="ID"></p-column>
+      <p-column field="host" header="Host"></p-column>
       <p-column field="email" header="Email"></p-column>
-      <p-column field="score" header="Score" [sortable]="true"></p-column>
+      <p-column field="score" header="Score"></p-column>
       <p-column field="createdAt" header="Created date" [sortable]="true"></p-column>
       <p-column header="Delete" styleClass="action-cell">
         <ng-template pTemplate="body" let-pod="rowData">

+ 1 - 1
client/src/app/+admin/friends/friend-list/friend-list.component.ts

@@ -17,7 +17,7 @@ export class FriendListComponent extends RestTable implements OnInit {
   friends: Pod[] = []
   totalRecords = 0
   rowsPerPage = 10
-  sort: SortMeta = { field: 'id', order: 1 }
+  sort: SortMeta = { field: 'createdAt', order: 1 }
   pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
 
   constructor (

+ 2 - 2
client/src/app/+admin/friends/shared/friend.service.ts

@@ -23,7 +23,7 @@ export class FriendService {
     let params = new HttpParams()
     params = this.restService.addRestGetParams(params, pagination, sort)
 
-    return this.authHttp.get<ResultList<Account>>(API_URL + '/followers', { params })
+    return this.authHttp.get<ResultList<Account>>(API_URL + '/api/v1/pods/followers', { params })
                         .map(res => this.restExtractor.convertResultListDateToHuman(res))
                         .catch(res => this.restExtractor.handleError(res))
   }
@@ -33,7 +33,7 @@ export class FriendService {
       hosts: notEmptyHosts
     }
 
-    return this.authHttp.post(API_URL + '/follow', body)
+    return this.authHttp.post(API_URL + '/api/v1/pods/follow', body)
                         .map(this.restExtractor.extractDataBool)
                         .catch(res => this.restExtractor.handleError(res))
   }

+ 4 - 1
server.ts

@@ -47,7 +47,7 @@ db.init(false).then(() => onDatabaseInitDone())
 // ----------- PeerTube modules -----------
 import { migrate, installApplication } from './server/initializers'
 import { httpRequestJobScheduler, transcodingJobScheduler, VideosPreviewCache } from './server/lib'
-import { apiRouter, clientsRouter, staticRouter, servicesRouter } from './server/controllers'
+import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers'
 
 // ----------- Command line -----------
 
@@ -115,6 +115,9 @@ app.use(apiRoute, apiRouter)
 // Services (oembed...)
 app.use('/services', servicesRouter)
 
+app.use('/', webfingerRouter)
+app.use('/', activityPubRouter)
+
 // Client files
 app.use('/', clientsRouter)
 

+ 2 - 2
server/controllers/activitypub/client.ts

@@ -16,12 +16,12 @@ activityPubClientRouter.get('/account/:name',
   executeIfActivityPub(asyncMiddleware(accountController))
 )
 
-activityPubClientRouter.get('/account/:nameWithHost/followers',
+activityPubClientRouter.get('/account/:name/followers',
   executeIfActivityPub(localAccountValidator),
   executeIfActivityPub(asyncMiddleware(accountFollowersController))
 )
 
-activityPubClientRouter.get('/account/:nameWithHost/following',
+activityPubClientRouter.get('/account/:name/following',
   executeIfActivityPub(localAccountValidator),
   executeIfActivityPub(asyncMiddleware(accountFollowingController))
 )

+ 3 - 1
server/controllers/activitypub/inbox.ts

@@ -30,7 +30,7 @@ inboxRouter.post('/inbox',
   asyncMiddleware(inboxController)
 )
 
-inboxRouter.post('/:nameWithHost/inbox',
+inboxRouter.post('/account/:name/inbox',
   signatureValidator,
   asyncMiddleware(checkSignature),
   localAccountValidator,
@@ -59,7 +59,9 @@ async function inboxController (req: express.Request, res: express.Response, nex
   }
 
   // Only keep activities we are able to process
+  logger.debug('Filtering activities...', { activities })
   activities = activities.filter(a => isActivityValid(a))
+  logger.debug('We keep %d activities.', activities.length, { activities })
 
   await processActivities(activities, res.locals.account)
 

+ 5 - 5
server/controllers/activitypub/index.ts

@@ -4,14 +4,14 @@ import { badRequest } from '../../helpers'
 import { inboxRouter } from './inbox'
 import { activityPubClientRouter } from './client'
 
-const remoteRouter = express.Router()
+const activityPubRouter = express.Router()
 
-remoteRouter.use('/', inboxRouter)
-remoteRouter.use('/', activityPubClientRouter)
-remoteRouter.use('/*', badRequest)
+activityPubRouter.use('/', inboxRouter)
+activityPubRouter.use('/', activityPubClientRouter)
+activityPubRouter.use('/*', badRequest)
 
 // ---------------------------------------------------------------------------
 
 export {
-  remoteRouter
+  activityPubRouter
 }

+ 51 - 17
server/controllers/api/pods.ts

@@ -1,19 +1,19 @@
-import * as Bluebird from 'bluebird'
 import * as express from 'express'
+import { UserRight } from '../../../shared/models/users/user-right.enum'
 import { getFormattedObjects } from '../../helpers'
-import { getOrCreateAccount } from '../../helpers/activitypub'
+import { logger } from '../../helpers/logger'
 import { getApplicationAccount } from '../../helpers/utils'
-import { REMOTE_SCHEME } from '../../initializers/constants'
+import { getAccountFromWebfinger } from '../../helpers/webfinger'
+import { SERVER_ACCOUNT_NAME } from '../../initializers/constants'
 import { database as db } from '../../initializers/database'
+import { sendFollow } from '../../lib/activitypub/send-request'
 import { asyncMiddleware, paginationValidator, setFollowersSort, setPagination } from '../../middlewares'
+import { authenticate } from '../../middlewares/oauth'
 import { setBodyHostsPort } from '../../middlewares/pods'
 import { setFollowingSort } from '../../middlewares/sort'
+import { ensureUserHasRight } from '../../middlewares/user-right'
 import { followValidator } from '../../middlewares/validators/pods'
 import { followersSortValidator, followingSortValidator } from '../../middlewares/validators/sort'
-import { sendFollow } from '../../lib/activitypub/send-request'
-import { authenticate } from '../../middlewares/oauth'
-import { ensureUserHasRight } from '../../middlewares/user-right'
-import { UserRight } from '../../../shared/models/users/user-right.enum'
 
 const podsRouter = express.Router()
 
@@ -67,22 +67,43 @@ async function follow (req: express.Request, res: express.Response, next: expres
   const hosts = req.body.hosts as string[]
   const fromAccount = await getApplicationAccount()
 
-  const tasks: Bluebird<any>[] = []
+  const tasks: Promise<any>[] = []
+  const accountName = SERVER_ACCOUNT_NAME
+
   for (const host of hosts) {
-    const url = REMOTE_SCHEME.HTTP + '://' + host
-    const targetAccount = await getOrCreateAccount(url)
 
     // We process each host in a specific transaction
     // First, we add the follow request in the database
     // Then we send the follow request to other account
-    const p = db.sequelize.transaction(async t => {
-      return db.AccountFollow.create({
-        accountId: fromAccount.id,
-        targetAccountId: targetAccount.id,
-        state: 'pending'
+    const p = loadLocalOrGetAccountFromWebfinger(accountName, host)
+      .then(accountResult => {
+        let targetAccount = accountResult.account
+
+        return db.sequelize.transaction(async t => {
+          if (accountResult.loadedFromDB === false) {
+            targetAccount = await targetAccount.save({ transaction: t })
+          }
+
+          const [ accountFollow ] = await db.AccountFollow.findOrCreate({
+            where: {
+              accountId: fromAccount.id,
+              targetAccountId: targetAccount.id
+            },
+            defaults: {
+              state: 'pending',
+              accountId: fromAccount.id,
+              targetAccountId: targetAccount.id
+            },
+            transaction: t
+          })
+
+          // Send a notification to remote server
+          if (accountFollow.state === 'pending') {
+            await sendFollow(fromAccount, targetAccount, t)
+          }
+        })
       })
-      .then(() => sendFollow(fromAccount, targetAccount, t))
-    })
+      .catch(err => logger.warn('Cannot follow server %s.', `${accountName}@${host}`, err))
 
     tasks.push(p)
   }
@@ -91,3 +112,16 @@ async function follow (req: express.Request, res: express.Response, next: expres
 
   return res.status(204).end()
 }
+
+async function loadLocalOrGetAccountFromWebfinger (name: string, host: string) {
+  let loadedFromDB = true
+  let account = await db.Account.loadByNameAndHost(name, host)
+
+  if (!account) {
+    const nameWithDomain = name + '@' + host
+    account = await getAccountFromWebfinger(nameWithDomain)
+    loadedFromDB = false
+  }
+
+  return { account, loadedFromDB }
+}

+ 2 - 0
server/controllers/index.ts

@@ -1,4 +1,6 @@
+export * from './activitypub'
 export * from './static'
 export * from './client'
 export * from './services'
 export * from './api'
+export * from './webfinger'

+ 39 - 0
server/controllers/webfinger.ts

@@ -0,0 +1,39 @@
+import * as express from 'express'
+
+import { CONFIG, PREVIEWS_SIZE, EMBED_SIZE } from '../initializers'
+import { oembedValidator } from '../middlewares'
+import { VideoInstance } from '../models'
+import { webfingerValidator } from '../middlewares/validators/webfinger'
+import { AccountInstance } from '../models/account/account-interface'
+
+const webfingerRouter = express.Router()
+
+webfingerRouter.use('/.well-known/webfinger',
+  webfingerValidator,
+  webfingerController
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  webfingerRouter
+}
+
+// ---------------------------------------------------------------------------
+
+function webfingerController (req: express.Request, res: express.Response, next: express.NextFunction) {
+  const account: AccountInstance = res.locals.account
+
+  const json = {
+    subject: req.query.resource,
+    aliases: [ account.url ],
+    links: [
+      {
+        rel: 'self',
+        href: account.url
+      }
+    ]
+  }
+
+  return res.json(json).end()
+}

+ 19 - 10
server/helpers/activitypub.ts

@@ -5,7 +5,7 @@ import { ActivityIconObject } from '../../shared/index'
 import { ActivityPubActor } from '../../shared/models/activitypub/activitypub-actor'
 import { ResultList } from '../../shared/models/result-list.model'
 import { database as db, REMOTE_SCHEME } from '../initializers'
-import { CONFIG, STATIC_PATHS } from '../initializers/constants'
+import { ACTIVITY_PUB_ACCEPT_HEADER, CONFIG, STATIC_PATHS } from '../initializers/constants'
 import { VideoInstance } from '../models/video/video-interface'
 import { isRemoteAccountValid } from './custom-validators'
 import { logger } from './logger'
@@ -35,11 +35,11 @@ async function getOrCreateAccount (accountUrl: string) {
 
   // We don't have this account in our database, fetch it on remote
   if (!account) {
-    const { account } = await fetchRemoteAccountAndCreatePod(accountUrl)
-
-    if (!account) throw new Error('Cannot fetch remote account.')
+    const res = await fetchRemoteAccountAndCreatePod(accountUrl)
+    if (res === undefined) throw new Error('Cannot fetch remote account.')
 
     // Save our new account in database
+    const account = res.account
     await account.save()
   }
 
@@ -49,19 +49,27 @@ async function getOrCreateAccount (accountUrl: string) {
 async function fetchRemoteAccountAndCreatePod (accountUrl: string) {
   const options = {
     uri: accountUrl,
-    method: 'GET'
+    method: 'GET',
+    headers: {
+      'Accept': ACTIVITY_PUB_ACCEPT_HEADER
+    }
   }
 
+  logger.info('Fetching remote account %s.', accountUrl)
+
   let requestResult
   try {
     requestResult = await doRequest(options)
   } catch (err) {
-    logger.warning('Cannot fetch remote account %s.', accountUrl, err)
+    logger.warn('Cannot fetch remote account %s.', accountUrl, err)
     return undefined
   }
 
-  const accountJSON: ActivityPubActor = requestResult.body
-  if (isRemoteAccountValid(accountJSON) === false) return undefined
+  const accountJSON: ActivityPubActor = JSON.parse(requestResult.body)
+  if (isRemoteAccountValid(accountJSON) === false) {
+    logger.debug('Remote account JSON is not valid.', { accountJSON })
+    return undefined
+  }
 
   const followersCount = await fetchAccountCount(accountJSON.followers)
   const followingCount = await fetchAccountCount(accountJSON.following)
@@ -90,7 +98,8 @@ async function fetchRemoteAccountAndCreatePod (accountUrl: string) {
       host: accountHost
     }
   }
-  const pod = await db.Pod.findOrCreate(podOptions)
+  const [ pod ] = await db.Pod.findOrCreate(podOptions)
+  account.set('podId', pod.id)
 
   return { account, pod }
 }
@@ -176,7 +185,7 @@ async function fetchAccountCount (url: string) {
   try {
     requestResult = await doRequest(options)
   } catch (err) {
-    logger.warning('Cannot fetch remote account count %s.', url, err)
+    logger.warn('Cannot fetch remote account count %s.', url, err)
     return undefined
   }
 

+ 7 - 7
server/helpers/custom-validators/video-accounts.ts → server/helpers/custom-validators/accounts.ts

@@ -10,14 +10,14 @@ import { logger } from '../logger'
 import { isUserUsernameValid } from './users'
 import { isHostValid } from './pods'
 
-function isVideoAccountNameValid (value: string) {
+function isAccountNameValid (value: string) {
   return isUserUsernameValid(value)
 }
 
 function isAccountNameWithHostValid (value: string) {
   const [ name, host ] = value.split('@')
 
-  return isVideoAccountNameValid(name) && isHostValid(host)
+  return isAccountNameValid(name) && isHostValid(host)
 }
 
 function checkVideoAccountExists (id: string, res: express.Response, callback: () => void) {
@@ -38,10 +38,10 @@ function checkVideoAccountExists (id: string, res: express.Response, callback: (
     res.locals.account = account
     callback()
   })
-    .catch(err => {
-      logger.error('Error in video account request validator.', err)
-      return res.sendStatus(500)
-    })
+  .catch(err => {
+    logger.error('Error in video account request validator.', err)
+    return res.sendStatus(500)
+  })
 }
 
 // ---------------------------------------------------------------------------
@@ -49,5 +49,5 @@ function checkVideoAccountExists (id: string, res: express.Response, callback: (
 export {
   checkVideoAccountExists,
   isAccountNameWithHostValid,
-  isVideoAccountNameValid
+  isAccountNameValid
 }

+ 21 - 10
server/helpers/custom-validators/activitypub/account.ts

@@ -1,9 +1,8 @@
 import * as validator from 'validator'
-
-import { exists, isUUIDValid } from '../misc'
-import { isActivityPubUrlValid } from './misc'
-import { isUserUsernameValid } from '../users'
 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
+import { isAccountNameValid } from '../accounts'
+import { exists, isUUIDValid } from '../misc'
+import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
 
 function isAccountEndpointsObjectValid (endpointObject: any) {
   return isAccountSharedInboxValid(endpointObject.sharedInbox)
@@ -59,10 +58,6 @@ function isAccountOutboxValid (outbox: string) {
   return isActivityPubUrlValid(outbox)
 }
 
-function isAccountNameValid (name: string) {
-  return isUserUsernameValid(name)
-}
-
 function isAccountPreferredUsernameValid (preferredUsername: string) {
   return isAccountNameValid(preferredUsername)
 }
@@ -90,7 +85,7 @@ function isRemoteAccountValid (remoteAccount: any) {
     isAccountPreferredUsernameValid(remoteAccount.preferredUsername) &&
     isAccountUrlValid(remoteAccount.url) &&
     isAccountPublicKeyObjectValid(remoteAccount.publicKey) &&
-    isAccountEndpointsObjectValid(remoteAccount.endpoint)
+    isAccountEndpointsObjectValid(remoteAccount.endpoints)
 }
 
 function isAccountFollowingCountValid (value: string) {
@@ -101,6 +96,19 @@ function isAccountFollowersCountValid (value: string) {
   return exists(value) && validator.isInt('' + value, { min: 0 })
 }
 
+function isAccountDeleteActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Delete')
+}
+
+function isAccountFollowActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Follow') &&
+    isActivityPubUrlValid(activity.object)
+}
+
+function isAccountAcceptActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Accept')
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -122,5 +130,8 @@ export {
   isRemoteAccountValid,
   isAccountFollowingCountValid,
   isAccountFollowersCountValid,
-  isAccountNameValid
+  isAccountNameValid,
+  isAccountFollowActivityValid,
+  isAccountAcceptActivityValid,
+  isAccountDeleteActivityValid
 }

+ 14 - 5
server/helpers/custom-validators/activitypub/activity.ts

@@ -1,9 +1,13 @@
 import * as validator from 'validator'
+import { isAccountAcceptActivityValid, isAccountDeleteActivityValid, isAccountFollowActivityValid } from './account'
+import { isActivityPubUrlValid } from './misc'
 import {
   isVideoChannelCreateActivityValid,
+  isVideoChannelDeleteActivityValid,
+  isVideoChannelUpdateActivityValid,
   isVideoTorrentAddActivityValid,
-  isVideoTorrentUpdateActivityValid,
-  isVideoChannelUpdateActivityValid
+  isVideoTorrentDeleteActivityValid,
+  isVideoTorrentUpdateActivityValid
 } from './videos'
 
 function isRootActivityValid (activity: any) {
@@ -14,8 +18,8 @@ function isRootActivityValid (activity: any) {
       Array.isArray(activity.items)
     ) ||
     (
-      validator.isURL(activity.id) &&
-      validator.isURL(activity.actor)
+      isActivityPubUrlValid(activity.id) &&
+      isActivityPubUrlValid(activity.actor)
     )
 }
 
@@ -23,7 +27,12 @@ function isActivityValid (activity: any) {
   return isVideoTorrentAddActivityValid(activity) ||
     isVideoChannelCreateActivityValid(activity) ||
     isVideoTorrentUpdateActivityValid(activity) ||
-    isVideoChannelUpdateActivityValid(activity)
+    isVideoChannelUpdateActivityValid(activity) ||
+    isVideoTorrentDeleteActivityValid(activity) ||
+    isVideoChannelDeleteActivityValid(activity) ||
+    isAccountDeleteActivityValid(activity) ||
+    isAccountFollowActivityValid(activity) ||
+    isAccountAcceptActivityValid(activity)
 }
 
 // ---------------------------------------------------------------------------

+ 6 - 4
server/helpers/custom-validators/activitypub/misc.ts

@@ -23,10 +23,12 @@ function isActivityPubUrlValid (url: string) {
 function isBaseActivityValid (activity: any, type: string) {
   return Array.isArray(activity['@context']) &&
     activity.type === type &&
-    validator.isURL(activity.id) &&
-    validator.isURL(activity.actor) &&
-    Array.isArray(activity.to) &&
-    activity.to.every(t => validator.isURL(t))
+    isActivityPubUrlValid(activity.id) &&
+    isActivityPubUrlValid(activity.actor) &&
+    (
+      activity.to === undefined ||
+      (Array.isArray(activity.to) && activity.to.every(t => isActivityPubUrlValid(t)))
+    )
 }
 
 export {

+ 12 - 2
server/helpers/custom-validators/activitypub/videos.ts

@@ -14,7 +14,7 @@ import {
   isVideoUrlValid
 } from '../videos'
 import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../video-channels'
-import { isBaseActivityValid } from './misc'
+import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
 
 function isVideoTorrentAddActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Add') &&
@@ -26,6 +26,10 @@ function isVideoTorrentUpdateActivityValid (activity: any) {
     isVideoTorrentObjectValid(activity.object)
 }
 
+function isVideoTorrentDeleteActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Delete')
+}
+
 function isVideoTorrentObjectValid (video: any) {
   return video.type === 'Video' &&
     isVideoNameValid(video.name) &&
@@ -54,6 +58,10 @@ function isVideoChannelUpdateActivityValid (activity: any) {
     isVideoChannelObjectValid(activity.object)
 }
 
+function isVideoChannelDeleteActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Delete')
+}
+
 function isVideoChannelObjectValid (videoChannel: any) {
   return videoChannel.type === 'VideoChannel' &&
     isVideoChannelNameValid(videoChannel.name) &&
@@ -67,7 +75,9 @@ export {
   isVideoTorrentAddActivityValid,
   isVideoChannelCreateActivityValid,
   isVideoTorrentUpdateActivityValid,
-  isVideoChannelUpdateActivityValid
+  isVideoChannelUpdateActivityValid,
+  isVideoChannelDeleteActivityValid,
+  isVideoTorrentDeleteActivityValid
 }
 
 // ---------------------------------------------------------------------------

+ 2 - 1
server/helpers/custom-validators/index.ts

@@ -3,6 +3,7 @@ export * from './misc'
 export * from './pods'
 export * from './pods'
 export * from './users'
-export * from './video-accounts'
+export * from './accounts'
 export * from './video-channels'
 export * from './videos'
+export * from './webfinger'

+ 25 - 0
server/helpers/custom-validators/webfinger.ts

@@ -0,0 +1,25 @@
+import 'express-validator'
+import 'multer'
+import { CONFIG } from '../../initializers/constants'
+import { exists } from './misc'
+
+function isWebfingerResourceValid (value: string) {
+  if (!exists(value)) return false
+  if (value.startsWith('acct:') === false) return false
+
+  const accountWithHost = value.substr(5)
+  const accountParts = accountWithHost.split('@')
+  if (accountParts.length !== 2) return false
+
+  const host = accountParts[1]
+
+  if (host !== CONFIG.WEBSERVER.HOST) return false
+
+  return true
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isWebfingerResourceValid
+}

+ 12 - 9
server/helpers/webfinger.ts

@@ -12,17 +12,20 @@ const webfinger = new WebFinger({
   request_timeout: 3000
 })
 
-async function getAccountFromWebfinger (url: string) {
-  const webfingerData: WebFingerData = await webfingerLookup(url)
+async function getAccountFromWebfinger (nameWithHost: string) {
+  const webfingerData: WebFingerData = await webfingerLookup(nameWithHost)
 
-  if (Array.isArray(webfingerData.links) === false) return undefined
+  if (Array.isArray(webfingerData.links) === false) throw new Error('WebFinger links is not an array.')
 
   const selfLink = webfingerData.links.find(l => l.rel === 'self')
-  if (selfLink === undefined || isActivityPubUrlValid(selfLink.href) === false) return undefined
+  if (selfLink === undefined || isActivityPubUrlValid(selfLink.href) === false) {
+    throw new Error('Cannot find self link or href is not a valid URL.')
+  }
 
-  const { account } = await fetchRemoteAccountAndCreatePod(selfLink.href)
+  const res = await fetchRemoteAccountAndCreatePod(selfLink.href)
+  if (res === undefined) throw new Error('Cannot fetch and create pod of remote account ' + selfLink.href)
 
-  return account
+  return res.account
 }
 
 // ---------------------------------------------------------------------------
@@ -33,12 +36,12 @@ export {
 
 // ---------------------------------------------------------------------------
 
-function webfingerLookup (url: string) {
+function webfingerLookup (nameWithHost: string) {
   return new Promise<WebFingerData>((res, rej) => {
-    webfinger.lookup(url, (err, p) => {
+    webfinger.lookup(nameWithHost, (err, p) => {
       if (err) return rej(err)
 
-      return p
+      return res(p.object)
     })
   })
 }

+ 11 - 3
server/initializers/checker.ts

@@ -1,8 +1,8 @@
 import * as config from 'config'
-
 import { promisify0 } from '../helpers/core-utils'
-import { OAuthClientModel } from '../models/oauth/oauth-client-interface'
 import { UserModel } from '../models/account/user-interface'
+import { ApplicationModel } from '../models/application/application-interface'
+import { OAuthClientModel } from '../models/oauth/oauth-client-interface'
 
 // Some checks on configuration files
 function checkConfig () {
@@ -70,6 +70,13 @@ async function usersExist (User: UserModel) {
   return totalUsers !== 0
 }
 
+// We get db by param to not import it in this file (import orders)
+async function applicationExist (Application: ApplicationModel) {
+  const totalApplication = await Application.countTotal()
+
+  return totalApplication !== 0
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -77,5 +84,6 @@ export {
   checkFFmpeg,
   checkMissedConfig,
   clientsExist,
-  usersExist
+  usersExist,
+  applicationExist
 }

+ 5 - 0
server/initializers/constants.ts

@@ -226,6 +226,9 @@ const FRIEND_SCORE = {
   MAX: 1000
 }
 
+const SERVER_ACCOUNT_NAME = 'peertube'
+const ACTIVITY_PUB_ACCEPT_HEADER = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
+
 const ACTIVITY_PUB = {
   COLLECTION_ITEMS_PER_PAGE: 10,
   VIDEO_URL_MIME_TYPES: [
@@ -352,8 +355,10 @@ export {
   PODS_SCORE,
   PREVIEWS_SIZE,
   REMOTE_SCHEME,
+  ACTIVITY_PUB_ACCEPT_HEADER,
   FOLLOW_STATES,
   SEARCHABLE_COLUMNS,
+  SERVER_ACCOUNT_NAME,
   PRIVATE_RSA_KEY_SIZE,
   SORTABLE_COLUMNS,
   STATIC_MAX_AGE,

+ 7 - 3
server/initializers/installer.ts

@@ -3,8 +3,8 @@ import { UserRole } from '../../shared'
 import { logger, mkdirpPromise, rimrafPromise } from '../helpers'
 import { createUserAccountAndChannel } from '../lib'
 import { createLocalAccount } from '../lib/user'
-import { clientsExist, usersExist } from './checker'
-import { CACHE, CONFIG, LAST_MIGRATION_VERSION } from './constants'
+import { applicationExist, clientsExist, usersExist } from './checker'
+import { CACHE, CONFIG, LAST_MIGRATION_VERSION, SERVER_ACCOUNT_NAME } from './constants'
 
 import { database as db } from './database'
 
@@ -128,9 +128,13 @@ async function createOAuthAdminIfNotExist () {
 }
 
 async function createApplicationIfNotExist () {
+  const exist = await applicationExist(db.Application)
+  // Nothing to do, application already exist
+  if (exist === true) return undefined
+
   logger.info('Creating Application table.')
   const applicationInstance = await db.Application.create({ migrationVersion: LAST_MIGRATION_VERSION })
 
   logger.info('Creating application account.')
-  return createLocalAccount('peertube', null, applicationInstance.id, undefined)
+  return createLocalAccount(SERVER_ACCOUNT_NAME, null, applicationInstance.id, undefined)
 }

+ 1 - 1
server/lib/activitypub/process-add.ts

@@ -54,7 +54,7 @@ async function addRemoteVideo (account: AccountInstance, videoChannelUrl: string
 
     // Don't block on request
     generateThumbnailFromUrl(video, videoToCreateData.icon)
-      .catch(err => logger.warning('Cannot generate thumbnail of %s.', videoToCreateData.id, err))
+      .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoToCreateData.id, err))
 
     const videoCreated = await video.save(sequelizeOptions)
 

+ 11 - 7
server/lib/activitypub/process-follow.ts

@@ -36,14 +36,18 @@ async function follow (account: AccountInstance, targetAccountURL: string) {
     if (targetAccount === undefined) throw new Error('Unknown account')
     if (targetAccount.isOwned() === false) throw new Error('This is not a local account.')
 
-    const sequelizeOptions = {
+    await db.AccountFollow.findOrCreate({
+      where: {
+        accountId: account.id,
+        targetAccountId: targetAccount.id
+      },
+      defaults: {
+        accountId: account.id,
+        targetAccountId: targetAccount.id,
+        state: 'accepted'
+      },
       transaction: t
-    }
-    await db.AccountFollow.create({
-      accountId: account.id,
-      targetAccountId: targetAccount.id,
-      state: 'accepted'
-    }, sequelizeOptions)
+    })
 
     // Target sends to account he accepted the follow request
     return sendAccept(targetAccount, account, t)

+ 19 - 19
server/lib/activitypub/send-request.ts

@@ -10,60 +10,60 @@ import { httpRequestJobScheduler } from '../jobs'
 import { signObject, activityPubContextify } from '../../helpers'
 import { Activity } from '../../../shared'
 
-function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
+async function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
   const videoChannelObject = videoChannel.toActivityPubObject()
-  const data = createActivityData(videoChannel.url, videoChannel.Account, videoChannelObject)
+  const data = await createActivityData(videoChannel.url, videoChannel.Account, videoChannelObject)
 
   return broadcastToFollowers(data, videoChannel.Account, t)
 }
 
-function sendUpdateVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
+async function sendUpdateVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
   const videoChannelObject = videoChannel.toActivityPubObject()
-  const data = updateActivityData(videoChannel.url, videoChannel.Account, videoChannelObject)
+  const data = await updateActivityData(videoChannel.url, videoChannel.Account, videoChannelObject)
 
   return broadcastToFollowers(data, videoChannel.Account, t)
 }
 
-function sendDeleteVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
-  const data = deleteActivityData(videoChannel.url, videoChannel.Account)
+async function sendDeleteVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
+  const data = await deleteActivityData(videoChannel.url, videoChannel.Account)
 
   return broadcastToFollowers(data, videoChannel.Account, t)
 }
 
-function sendAddVideo (video: VideoInstance, t: Sequelize.Transaction) {
+async function sendAddVideo (video: VideoInstance, t: Sequelize.Transaction) {
   const videoObject = video.toActivityPubObject()
-  const data = addActivityData(video.url, video.VideoChannel.Account, video.VideoChannel.url, videoObject)
+  const data = await addActivityData(video.url, video.VideoChannel.Account, video.VideoChannel.url, videoObject)
 
   return broadcastToFollowers(data, video.VideoChannel.Account, t)
 }
 
-function sendUpdateVideo (video: VideoInstance, t: Sequelize.Transaction) {
+async function sendUpdateVideo (video: VideoInstance, t: Sequelize.Transaction) {
   const videoObject = video.toActivityPubObject()
-  const data = updateActivityData(video.url, video.VideoChannel.Account, videoObject)
+  const data = await updateActivityData(video.url, video.VideoChannel.Account, videoObject)
 
   return broadcastToFollowers(data, video.VideoChannel.Account, t)
 }
 
-function sendDeleteVideo (video: VideoInstance, t: Sequelize.Transaction) {
-  const data = deleteActivityData(video.url, video.VideoChannel.Account)
+async function sendDeleteVideo (video: VideoInstance, t: Sequelize.Transaction) {
+  const data = await deleteActivityData(video.url, video.VideoChannel.Account)
 
   return broadcastToFollowers(data, video.VideoChannel.Account, t)
 }
 
-function sendDeleteAccount (account: AccountInstance, t: Sequelize.Transaction) {
-  const data = deleteActivityData(account.url, account)
+async function sendDeleteAccount (account: AccountInstance, t: Sequelize.Transaction) {
+  const data = await deleteActivityData(account.url, account)
 
   return broadcastToFollowers(data, account, t)
 }
 
-function sendAccept (fromAccount: AccountInstance, toAccount: AccountInstance, t: Sequelize.Transaction) {
-  const data = acceptActivityData(fromAccount)
+async function sendAccept (fromAccount: AccountInstance, toAccount: AccountInstance, t: Sequelize.Transaction) {
+  const data = await acceptActivityData(fromAccount)
 
   return unicastTo(data, toAccount, t)
 }
 
-function sendFollow (fromAccount: AccountInstance, toAccount: AccountInstance, t: Sequelize.Transaction) {
-  const data = followActivityData(toAccount.url, fromAccount)
+async function sendFollow (fromAccount: AccountInstance, toAccount: AccountInstance, t: Sequelize.Transaction) {
+  const data = await followActivityData(toAccount.url, fromAccount)
 
   return unicastTo(data, toAccount, t)
 }
@@ -97,7 +97,7 @@ async function broadcastToFollowers (data: any, fromAccount: AccountInstance, t:
 
 async function unicastTo (data: any, toAccount: AccountInstance, t: Sequelize.Transaction) {
   const jobPayload = {
-    uris: [ toAccount.url ],
+    uris: [ toAccount.inboxUrl ],
     body: data
   }
 

+ 1 - 0
server/lib/jobs/http-request-job-scheduler/http-request-broadcast-handler.ts

@@ -6,6 +6,7 @@ async function process (payload: HTTPRequestPayload, jobId: number) {
   logger.info('Processing broadcast in job %d.', jobId)
 
   const options = {
+    method: 'POST',
     uri: '',
     json: payload.body
   }

+ 1 - 0
server/lib/jobs/http-request-job-scheduler/http-request-unicast-handler.ts

@@ -7,6 +7,7 @@ async function process (payload: HTTPRequestPayload, jobId: number) {
 
   const uri = payload.uris[0]
   const options = {
+    method: 'POST',
     uri,
     json: payload.body
   }

+ 9 - 4
server/lib/jobs/job-scheduler.ts

@@ -4,6 +4,7 @@ import { JobCategory } from '../../../shared'
 import { logger } from '../../helpers'
 import { database as db, JOB_STATES, JOBS_FETCH_LIMIT_PER_CYCLE, JOBS_FETCHING_INTERVAL } from '../../initializers'
 import { JobInstance } from '../../models'
+import { error } from 'util'
 
 export interface JobHandler<P, T> {
   process (data: object, jobId: number): Promise<T>
@@ -80,8 +81,12 @@ class JobScheduler<P, T> {
   private async processJob (job: JobInstance, callback: (err: Error) => void) {
     const jobHandler = this.jobHandlers[job.handlerName]
     if (jobHandler === undefined) {
-      logger.error('Unknown job handler for job %s.', job.handlerName)
-      return callback(null)
+      const errorString = 'Unknown job handler ' + job.handlerName + ' for job ' + job.id
+      logger.error(errorString)
+
+      const error = new Error(errorString)
+      await this.onJobError(jobHandler, job, error)
+      return callback(error)
     }
 
     logger.info('Processing job %d with handler %s.', job.id, job.handlerName)
@@ -103,7 +108,7 @@ class JobScheduler<P, T> {
       }
     }
 
-    callback(null)
+    return callback(null)
   }
 
   private async onJobError (jobHandler: JobHandler<P, T>, job: JobInstance, err: Error) {
@@ -111,7 +116,7 @@ class JobScheduler<P, T> {
 
     try {
       await job.save()
-      await jobHandler.onError(err, job.id)
+      if (jobHandler) await jobHandler.onError(err, job.id)
     } catch (err) {
       this.cannotSaveJobError(err)
     }

+ 17 - 15
server/middlewares/activitypub.ts

@@ -1,12 +1,9 @@
-import { Request, Response, NextFunction } from 'express'
-
-import { database as db } from '../initializers'
-import {
-  logger,
-  getAccountFromWebfinger,
-  isSignatureVerified
-} from '../helpers'
+import { NextFunction, Request, Response, RequestHandler } from 'express'
 import { ActivityPubSignature } from '../../shared'
+import { isSignatureVerified, logger } from '../helpers'
+import { fetchRemoteAccountAndCreatePod } from '../helpers/activitypub'
+import { database as db, ACTIVITY_PUB_ACCEPT_HEADER } from '../initializers'
+import { each, eachSeries, waterfall } from 'async'
 
 async function checkSignature (req: Request, res: Response, next: NextFunction) {
   const signatureObject: ActivityPubSignature = req.body.signature
@@ -17,35 +14,40 @@ async function checkSignature (req: Request, res: Response, next: NextFunction)
 
   // We don't have this account in our database, fetch it on remote
   if (!account) {
-    account = await getAccountFromWebfinger(signatureObject.creator)
+    const accountResult = await fetchRemoteAccountAndCreatePod(signatureObject.creator)
 
-    if (!account) {
+    if (!accountResult) {
       return res.sendStatus(403)
     }
 
     // Save our new account in database
+    account = accountResult.account
     await account.save()
   }
 
   const verified = await isSignatureVerified(account, req.body)
   if (verified === false) return res.sendStatus(403)
 
-  res.locals.signature.account = account
+  res.locals.signature = {
+    account
+  }
 
   return next()
 }
 
-function executeIfActivityPub (fun: any | any[]) {
+function executeIfActivityPub (fun: RequestHandler | RequestHandler[]) {
   return (req: Request, res: Response, next: NextFunction) => {
-    if (req.header('Accept') !== 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') {
+    if (req.header('Accept') !== ACTIVITY_PUB_ACCEPT_HEADER) {
       return next()
     }
 
     if (Array.isArray(fun) === true) {
-      fun[0](req, res, next) // FIXME: doesn't work
+      return eachSeries(fun as RequestHandler[], (f, cb) => {
+        f(req, res, cb)
+      }, next)
     }
 
-    return fun(req, res, next)
+    return (fun as RequestHandler)(req, res, next)
   }
 }
 

+ 4 - 6
server/middlewares/validators/account.ts

@@ -8,13 +8,13 @@ import {
   isUserVideoQuotaValid,
   logger
 } from '../../helpers'
-import { isAccountNameWithHostValid } from '../../helpers/custom-validators/video-accounts'
+import { isAccountNameValid } from '../../helpers/custom-validators/accounts'
 import { database as db } from '../../initializers/database'
 import { AccountInstance } from '../../models'
 import { checkErrors } from './utils'
 
 const localAccountValidator = [
-  param('nameWithHost').custom(isAccountNameWithHostValid).withMessage('Should have a valid account with domain name (myuser@domain.tld)'),
+  param('name').custom(isAccountNameValid).withMessage('Should have a valid account name'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking localAccountValidator parameters', { parameters: req.params })
@@ -33,10 +33,8 @@ export {
 
 // ---------------------------------------------------------------------------
 
-function checkLocalAccountExists (nameWithHost: string, res: express.Response, callback: (err: Error, account: AccountInstance) => void) {
-  const [ name, host ] = nameWithHost.split('@')
-
-  db.Account.loadLocalAccountByNameAndPod(name, host)
+function checkLocalAccountExists (name: string, res: express.Response, callback: (err: Error, account: AccountInstance) => void) {
+  db.Account.loadLocalByName(name)
     .then(account => {
       if (!account) {
         return res.status(404)

+ 3 - 4
server/middlewares/validators/activitypub/activity.ts

@@ -1,11 +1,10 @@
-import { body } from 'express-validator/check'
 import * as express from 'express'
-
-import { logger, isRootActivityValid } from '../../../helpers'
+import { body } from 'express-validator/check'
+import { isRootActivityValid, logger } from '../../../helpers'
 import { checkErrors } from '../utils'
 
 const activityPubValidator = [
-  body('data').custom(isRootActivityValid),
+  body('').custom((value, { req }) => isRootActivityValid(req.body)),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking activity pub parameters', { parameters: req.body })

+ 1 - 0
server/middlewares/validators/index.ts

@@ -8,3 +8,4 @@ export * from './users'
 export * from './videos'
 export * from './video-blacklist'
 export * from './video-channels'
+export * from './webfinger'

+ 42 - 0
server/middlewares/validators/webfinger.ts

@@ -0,0 +1,42 @@
+import { query } from 'express-validator/check'
+import * as express from 'express'
+
+import { checkErrors } from './utils'
+import { logger, isWebfingerResourceValid } from '../../helpers'
+import { database as db } from '../../initializers'
+
+const webfingerValidator = [
+  query('resource').custom(isWebfingerResourceValid).withMessage('Should have a valid webfinger resource'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking webfinger parameters', { parameters: req.query })
+
+    checkErrors(req, res, () => {
+      // Remove 'acct:' from the beginning of the string
+      const nameWithHost = req.query.resource.substr(5)
+      const [ name, ] = nameWithHost.split('@')
+
+      db.Account.loadLocalByName(name)
+        .then(account => {
+          if (!account) {
+            return res.status(404)
+              .send({ error: 'Account not found' })
+              .end()
+          }
+
+          res.locals.account = account
+          return next()
+        })
+        .catch(err => {
+          logger.error('Error in webfinger validator.', err)
+          return res.sendStatus(500)
+        })
+    })
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  webfingerValidator
+}

+ 9 - 6
server/models/account/account-follow.ts

@@ -19,11 +19,13 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
     {
       indexes: [
         {
-          fields: [ 'accountId' ],
-          unique: true
+          fields: [ 'accountId' ]
+        },
+        {
+          fields: [ 'targetAccountId' ]
         },
         {
-          fields: [ 'targetAccountId' ],
+          fields: [ 'accountId', 'targetAccountId' ],
           unique: true
         }
       ]
@@ -31,7 +33,8 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
   )
 
   const classMethods = [
-    associate
+    associate,
+    loadByAccountAndTarget
   ]
   addMethodsToModel(AccountFollow, classMethods)
 
@@ -46,7 +49,7 @@ function associate (models) {
       name: 'accountId',
       allowNull: false
     },
-    as: 'followers',
+    as: 'accountFollowers',
     onDelete: 'CASCADE'
   })
 
@@ -55,7 +58,7 @@ function associate (models) {
       name: 'targetAccountId',
       allowNull: false
     },
-    as: 'following',
+    as: 'accountFollowing',
     onDelete: 'CASCADE'
   })
 }

+ 4 - 2
server/models/account/account-interface.ts

@@ -12,7 +12,8 @@ export namespace AccountMethods {
   export type LoadByUUID = (uuid: string) => Bluebird<AccountInstance>
   export type LoadByUrl = (url: string, transaction?: Sequelize.Transaction) => Bluebird<AccountInstance>
   export type LoadAccountByPodAndUUID = (uuid: string, podId: number, transaction: Sequelize.Transaction) => Bluebird<AccountInstance>
-  export type LoadLocalAccountByNameAndPod = (name: string, host: string) => Bluebird<AccountInstance>
+  export type LoadLocalByName = (name: string) => Bluebird<AccountInstance>
+  export type LoadByNameAndHost = (name: string, host: string) => Bluebird<AccountInstance>
   export type ListOwned = () => Bluebird<AccountInstance[]>
   export type ListAcceptedFollowerUrlsForApi = (id: number, start: number, count?: number) => Promise< ResultList<string> >
   export type ListAcceptedFollowingUrlsForApi = (id: number, start: number, count?: number) => Promise< ResultList<string> >
@@ -34,7 +35,8 @@ export interface AccountClass {
   load: AccountMethods.Load
   loadByUUID: AccountMethods.LoadByUUID
   loadByUrl: AccountMethods.LoadByUrl
-  loadLocalAccountByNameAndPod: AccountMethods.LoadLocalAccountByNameAndPod
+  loadLocalByName: AccountMethods.LoadLocalByName
+  loadByNameAndHost: AccountMethods.LoadByNameAndHost
   listOwned: AccountMethods.ListOwned
   listAcceptedFollowerUrlsForApi: AccountMethods.ListAcceptedFollowerUrlsForApi
   listAcceptedFollowingUrlsForApi: AccountMethods.ListAcceptedFollowingUrlsForApi

+ 34 - 10
server/models/account/account.ts

@@ -31,7 +31,8 @@ let load: AccountMethods.Load
 let loadApplication: AccountMethods.LoadApplication
 let loadByUUID: AccountMethods.LoadByUUID
 let loadByUrl: AccountMethods.LoadByUrl
-let loadLocalAccountByNameAndPod: AccountMethods.LoadLocalAccountByNameAndPod
+let loadLocalByName: AccountMethods.LoadLocalByName
+let loadByNameAndHost: AccountMethods.LoadByNameAndHost
 let listOwned: AccountMethods.ListOwned
 let listAcceptedFollowerUrlsForApi: AccountMethods.ListAcceptedFollowerUrlsForApi
 let listAcceptedFollowingUrlsForApi: AccountMethods.ListAcceptedFollowingUrlsForApi
@@ -88,7 +89,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
       },
       privateKey: {
         type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.PRIVATE_KEY.max),
-        allowNull: false,
+        allowNull: true,
         validate: {
           privateKeyValid: value => {
             const res = isAccountPrivateKeyValid(value)
@@ -199,7 +200,8 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
     load,
     loadByUUID,
     loadByUrl,
-    loadLocalAccountByNameAndPod,
+    loadLocalByName,
+    loadByNameAndHost,
     listOwned,
     listAcceptedFollowerUrlsForApi,
     listAcceptedFollowingUrlsForApi,
@@ -330,6 +332,8 @@ getFollowerSharedInboxUrls = function (this: AccountInstance) {
     include: [
       {
         model: Account['sequelize'].models.AccountFollow,
+        required: true,
+        as: 'followers',
         where: {
           targetAccountId: this.id
         }
@@ -387,7 +391,7 @@ listFollowingForApi = function (id: number, start: number, count: number, sort:
         include: [
           {
             model: Account['sequelize'].models.Account,
-            as: 'following',
+            as: 'accountFollowing',
             required: true,
             include: [ Account['sequelize'].models.Pod ]
           }
@@ -418,7 +422,7 @@ listFollowersForApi = function (id: number, start: number, count: number, sort:
         include: [
           {
             model: Account['sequelize'].models.Account,
-            as: 'followers',
+            as: 'accountFollowers',
             required: true,
             include: [ Account['sequelize'].models.Pod ]
           }
@@ -439,7 +443,7 @@ loadApplication = function () {
   return Account.findOne({
     include: [
       {
-        model: Account['sequelize'].model.Application,
+        model: Account['sequelize'].models.Application,
         required: true
       }
     ]
@@ -460,17 +464,37 @@ loadByUUID = function (uuid: string) {
   return Account.findOne(query)
 }
 
-loadLocalAccountByNameAndPod = function (name: string, host: string) {
+loadLocalByName = function (name: string) {
   const query: Sequelize.FindOptions<AccountAttributes> = {
     where: {
       name,
-      userId: {
-        [Sequelize.Op.ne]: null
-      }
+      [Sequelize.Op.or]: [
+        {
+          userId: {
+            [Sequelize.Op.ne]: null
+          }
+        },
+        {
+          applicationId: {
+            [Sequelize.Op.ne]: null
+          }
+        }
+      ]
+    }
+  }
+
+  return Account.findOne(query)
+}
+
+loadByNameAndHost = function (name: string, host: string) {
+  const query: Sequelize.FindOptions<AccountAttributes> = {
+    where: {
+      name
     },
     include: [
       {
         model: Account['sequelize'].models.Pod,
+        required: true,
         where: {
           host
         }

+ 6 - 3
server/models/application/application-interface.ts

@@ -1,18 +1,21 @@
 import * as Sequelize from 'sequelize'
-import * as Promise from 'bluebird'
+import * as Bluebird from 'bluebird'
 
 export namespace ApplicationMethods {
-  export type LoadMigrationVersion = () => Promise<number>
+  export type LoadMigrationVersion = () => Bluebird<number>
 
   export type UpdateMigrationVersion = (
     newVersion: number,
     transaction: Sequelize.Transaction
-  ) => Promise<[ number, ApplicationInstance[] ]>
+  ) => Bluebird<[ number, ApplicationInstance[] ]>
+
+  export type CountTotal = () => Bluebird<number>
 }
 
 export interface ApplicationClass {
   loadMigrationVersion: ApplicationMethods.LoadMigrationVersion
   updateMigrationVersion: ApplicationMethods.UpdateMigrationVersion
+  countTotal: ApplicationMethods.CountTotal
 }
 
 export interface ApplicationAttributes {

+ 10 - 1
server/models/application/application.ts

@@ -11,6 +11,7 @@ import {
 let Application: Sequelize.Model<ApplicationInstance, ApplicationAttributes>
 let loadMigrationVersion: ApplicationMethods.LoadMigrationVersion
 let updateMigrationVersion: ApplicationMethods.UpdateMigrationVersion
+let countTotal: ApplicationMethods.CountTotal
 
 export default function defineApplication (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
   Application = sequelize.define<ApplicationInstance, ApplicationAttributes>('Application',
@@ -26,7 +27,11 @@ export default function defineApplication (sequelize: Sequelize.Sequelize, DataT
     }
   )
 
-  const classMethods = [ loadMigrationVersion, updateMigrationVersion ]
+  const classMethods = [
+    countTotal,
+    loadMigrationVersion,
+    updateMigrationVersion
+  ]
   addMethodsToModel(Application, classMethods)
 
   return Application
@@ -34,6 +39,10 @@ export default function defineApplication (sequelize: Sequelize.Sequelize, DataT
 
 // ---------------------------------------------------------------------------
 
+countTotal = function () {
+  return this.count()
+}
+
 loadMigrationVersion = function () {
   const query = {
     attributes: [ 'migrationVersion' ]

+ 5 - 4
server/models/job/job.ts

@@ -10,7 +10,7 @@ import {
 
   JobMethods
 } from './job-interface'
-import { JobState } from '../../../shared/models/job.model'
+import { JobCategory, JobState } from '../../../shared/models/job.model'
 
 let Job: Sequelize.Model<JobInstance, JobAttributes>
 let listWithLimitByCategory: JobMethods.ListWithLimitByCategory
@@ -38,7 +38,7 @@ export default function defineJob (sequelize: Sequelize.Sequelize, DataTypes: Se
     {
       indexes: [
         {
-          fields: [ 'state' ]
+          fields: [ 'state', 'category' ]
         }
       ]
     }
@@ -52,14 +52,15 @@ export default function defineJob (sequelize: Sequelize.Sequelize, DataTypes: Se
 
 // ---------------------------------------------------------------------------
 
-listWithLimitByCategory = function (limit: number, state: JobState) {
+listWithLimitByCategory = function (limit: number, state: JobState, jobCategory: JobCategory) {
   const query = {
     order: [
       [ 'id', 'ASC' ]
     ],
     limit: limit,
     where: {
-      state
+      state,
+      category: jobCategory
     }
   }