Add ability for users to block an account/instance on server side
authorChocobozzz <me@florianbigard.com>
Fri, 12 Oct 2018 13:26:04 +0000 (15:26 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 16 Oct 2018 14:41:36 +0000 (16:41 +0200)
33 files changed:
server/controllers/api/accounts.ts
server/controllers/api/search.ts
server/controllers/api/users/index.ts
server/controllers/api/users/me.ts
server/controllers/api/users/my-blocklist.ts [new file with mode: 0644]
server/controllers/api/video-channel.ts
server/controllers/api/videos/comment.ts
server/controllers/api/videos/index.ts
server/helpers/utils.ts
server/initializers/constants.ts
server/initializers/database.ts
server/lib/blocklist.ts [new file with mode: 0644]
server/lib/video-comment.ts
server/middlewares/validators/blocklist.ts [new file with mode: 0644]
server/middlewares/validators/index.ts
server/middlewares/validators/server.ts [new file with mode: 0644]
server/middlewares/validators/sort.ts
server/models/account/account-blocklist.ts [new file with mode: 0644]
server/models/server/server-blocklist.ts [new file with mode: 0644]
server/models/server/server.ts
server/models/utils.ts
server/models/video/video-comment.ts
server/models/video/video.ts
server/tests/api/check-params/blocklist.ts [new file with mode: 0644]
server/tests/api/check-params/index.ts
server/tests/api/users/account-blocklist.ts [new file with mode: 0644]
server/tests/utils/requests/requests.ts
server/tests/utils/users/blocklist.ts [new file with mode: 0644]
server/tests/utils/videos/video-comments.ts
shared/models/blocklist/account-block.model.ts [new file with mode: 0644]
shared/models/blocklist/index.ts [new file with mode: 0644]
shared/models/blocklist/server-block.model.ts [new file with mode: 0644]
shared/models/index.ts

index 8e3f600108b9ef90b521820151777e7009ef99be..86ef2aed1f93cf925593a3c1ed12919c83e889ff 100644 (file)
@@ -1,7 +1,8 @@
 import * as express from 'express'
 import { getFormattedObjects } from '../../helpers/utils'
 import {
-  asyncMiddleware, commonVideosFiltersValidator,
+  asyncMiddleware,
+  commonVideosFiltersValidator,
   listVideoAccountChannelsValidator,
   optionalAuthenticate,
   paginationValidator,
@@ -90,7 +91,7 @@ async function listAccountVideos (req: express.Request, res: express.Response, n
     nsfw: buildNSFWFilter(res, req.query.nsfw),
     withFiles: false,
     accountId: account.id,
-    userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
+    user: res.locals.oauth ? res.locals.oauth.token.User : undefined
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
index a8a6cfb0821fb185602c0dd27c4b0783ffb3e13d..534305ba633b9eb68c457e1640dc135b29362c63 100644 (file)
@@ -119,7 +119,7 @@ async function searchVideosDB (query: VideosSearchQuery, res: express.Response)
     includeLocalVideos: true,
     nsfw: buildNSFWFilter(res, query.nsfw),
     filter: query.filter,
-    userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
+    user: res.locals.oauth ? res.locals.oauth.token.User : undefined
   })
   const resultList = await VideoModel.searchAndPopulateAccountAndServer(options)
 
index 4f8137c03943b2717bf0fad4b2c2e2aaecc67a08..9fcb8077f4e451c7ff14ec34c5de8526edcb220b 100644 (file)
@@ -37,6 +37,7 @@ import { UserModel } from '../../../models/account/user'
 import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
 import { meRouter } from './me'
 import { deleteUserToken } from '../../../lib/oauth-model'
+import { myBlocklistRouter } from './my-blocklist'
 
 const auditLogger = auditLoggerFactory('users')
 
@@ -53,6 +54,7 @@ const askSendEmailLimiter = new RateLimit({
 })
 
 const usersRouter = express.Router()
+usersRouter.use('/', myBlocklistRouter)
 usersRouter.use('/', meRouter)
 
 usersRouter.get('/autocomplete',
index 591ec6b254b9085e506125fc050738272e16135b..ebe6681102554fdfda7c3ccb45c55d92b9d2c234 100644 (file)
@@ -238,7 +238,8 @@ async function getUserSubscriptionVideos (req: express.Request, res: express.Res
     nsfw: buildNSFWFilter(res, req.query.nsfw),
     filter: req.query.filter as VideoFilter,
     withFiles: false,
-    actorId: user.Account.Actor.id
+    actorId: user.Account.Actor.id,
+    user
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
diff --git a/server/controllers/api/users/my-blocklist.ts b/server/controllers/api/users/my-blocklist.ts
new file mode 100644 (file)
index 0000000..e955ffd
--- /dev/null
@@ -0,0 +1,125 @@
+import * as express from 'express'
+import 'multer'
+import { getFormattedObjects } from '../../../helpers/utils'
+import {
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  paginationValidator,
+  serverGetValidator,
+  setDefaultPagination,
+  setDefaultSort,
+  unblockAccountByAccountValidator
+} from '../../../middlewares'
+import {
+  accountsBlocklistSortValidator,
+  blockAccountByAccountValidator,
+  serversBlocklistSortValidator,
+  unblockServerByAccountValidator
+} from '../../../middlewares/validators'
+import { UserModel } from '../../../models/account/user'
+import { AccountModel } from '../../../models/account/account'
+import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
+import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist'
+import { ServerBlocklistModel } from '../../../models/server/server-blocklist'
+import { ServerModel } from '../../../models/server/server'
+
+const myBlocklistRouter = express.Router()
+
+myBlocklistRouter.get('/me/blocklist/accounts',
+  authenticate,
+  paginationValidator,
+  accountsBlocklistSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listBlockedAccounts)
+)
+
+myBlocklistRouter.post('/me/blocklist/accounts',
+  authenticate,
+  asyncMiddleware(blockAccountByAccountValidator),
+  asyncRetryTransactionMiddleware(blockAccount)
+)
+
+myBlocklistRouter.delete('/me/blocklist/accounts/:accountName',
+  authenticate,
+  asyncMiddleware(unblockAccountByAccountValidator),
+  asyncRetryTransactionMiddleware(unblockAccount)
+)
+
+myBlocklistRouter.get('/me/blocklist/servers',
+  authenticate,
+  paginationValidator,
+  serversBlocklistSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listBlockedServers)
+)
+
+myBlocklistRouter.post('/me/blocklist/servers',
+  authenticate,
+  asyncMiddleware(serverGetValidator),
+  asyncRetryTransactionMiddleware(blockServer)
+)
+
+myBlocklistRouter.delete('/me/blocklist/servers/:host',
+  authenticate,
+  asyncMiddleware(unblockServerByAccountValidator),
+  asyncRetryTransactionMiddleware(unblockServer)
+)
+
+export {
+  myBlocklistRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listBlockedAccounts (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+
+  const resultList = await AccountBlocklistModel.listForApi(user.Account.id, req.query.start, req.query.count, req.query.sort)
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function blockAccount (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+  const accountToBlock: AccountModel = res.locals.account
+
+  await addAccountInBlocklist(user.Account.id, accountToBlock.id)
+
+  return res.status(204).end()
+}
+
+async function unblockAccount (req: express.Request, res: express.Response) {
+  const accountBlock: AccountBlocklistModel = res.locals.accountBlock
+
+  await removeAccountFromBlocklist(accountBlock)
+
+  return res.status(204).end()
+}
+
+async function listBlockedServers (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+
+  const resultList = await ServerBlocklistModel.listForApi(user.Account.id, req.query.start, req.query.count, req.query.sort)
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function blockServer (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+  const serverToBlock: ServerModel = res.locals.server
+
+  await addServerInBlocklist(user.Account.id, serverToBlock.id)
+
+  return res.status(204).end()
+}
+
+async function unblockServer (req: express.Request, res: express.Response) {
+  const serverBlock: ServerBlocklistModel = res.locals.serverBlock
+
+  await removeServerFromBlocklist(serverBlock)
+
+  return res.status(204).end()
+}
index c84d1be580f8aa44dbafcb7ca618ac093f99029a..9bf3c5fd808e1e2c9fa865442f9435a830b739a2 100644 (file)
@@ -219,7 +219,7 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
     nsfw: buildNSFWFilter(res, req.query.nsfw),
     withFiles: false,
     videoChannelId: videoChannelInstance.id,
-    userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
+    user: res.locals.oauth ? res.locals.oauth.token.User : undefined
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
index 4f2b4faeeeefbbd68eab9823eb47caad338d7ac9..3875c8f79baf1c0e3f7041cd8bf54549e0ecca17 100644 (file)
@@ -8,7 +8,7 @@ import { buildFormattedCommentTree, createVideoComment } from '../../../lib/vide
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
-  authenticate,
+  authenticate, optionalAuthenticate,
   paginationValidator,
   setDefaultPagination,
   setDefaultSort
@@ -36,10 +36,12 @@ videoCommentRouter.get('/:videoId/comment-threads',
   setDefaultSort,
   setDefaultPagination,
   asyncMiddleware(listVideoCommentThreadsValidator),
+  optionalAuthenticate,
   asyncMiddleware(listVideoThreads)
 )
 videoCommentRouter.get('/:videoId/comment-threads/:threadId',
   asyncMiddleware(listVideoThreadCommentsValidator),
+  optionalAuthenticate,
   asyncMiddleware(listVideoThreadComments)
 )
 
@@ -69,10 +71,12 @@ export {
 
 async function listVideoThreads (req: express.Request, res: express.Response, next: express.NextFunction) {
   const video = res.locals.video as VideoModel
+  const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
+
   let resultList: ResultList<VideoCommentModel>
 
   if (video.commentsEnabled === true) {
-    resultList = await VideoCommentModel.listThreadsForApi(video.id, req.query.start, req.query.count, req.query.sort)
+    resultList = await VideoCommentModel.listThreadsForApi(video.id, req.query.start, req.query.count, req.query.sort, user)
   } else {
     resultList = {
       total: 0,
@@ -85,10 +89,12 @@ async function listVideoThreads (req: express.Request, res: express.Response, ne
 
 async function listVideoThreadComments (req: express.Request, res: express.Response, next: express.NextFunction) {
   const video = res.locals.video as VideoModel
+  const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
+
   let resultList: ResultList<VideoCommentModel>
 
   if (video.commentsEnabled === true) {
-    resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id)
+    resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id, user)
   } else {
     resultList = {
       total: 0,
index 6a73e13d00c64da1b16ad5c77baefb6fad89e543..6641544066cd858c6133f2498234e8222bbbd121 100644 (file)
@@ -437,7 +437,7 @@ async function listVideos (req: express.Request, res: express.Response, next: ex
     nsfw: buildNSFWFilter(res, req.query.nsfw),
     filter: req.query.filter as VideoFilter,
     withFiles: false,
-    userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined
+    user: res.locals.oauth ? res.locals.oauth.token.User : undefined
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
index 39afb4e7b97fa898723fe56d44506d433a618fa3..049c3f8bcedc8d145022ca6066a5ca9291452ae4 100644 (file)
@@ -40,7 +40,10 @@ const getServerActor = memoizee(async function () {
   const application = await ApplicationModel.load()
   if (!application) throw Error('Could not load Application from database.')
 
-  return application.Account.Actor
+  const actor = application.Account.Actor
+  actor.Account = application.Account
+
+  return actor
 })
 
 function generateVideoTmpPath (target: string | ParseTorrent) {
index 49ee13c10dd75f3d99c1e4d9da7c4fcefc1cc236..cf00da2c7fe7d2cc1170b08f169dccf6019ce9a0 100644 (file)
@@ -47,7 +47,10 @@ const SORTABLE_COLUMNS = {
   VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'trending' ],
 
   VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'views', 'likes', 'match' ],
-  VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ]
+  VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
+
+  ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
+  SERVERS_BLOCKLIST: [ 'createdAt' ]
 }
 
 const OAUTH_LIFETIME = {
index 482c03b31c2f9a259334480808aadc245de1b7c9..dd5b9bf67db8930db50492668a5b22b113c6128a 100644 (file)
@@ -29,6 +29,8 @@ import { VideoViewModel } from '../models/video/video-views'
 import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
 import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
 import { UserVideoHistoryModel } from '../models/account/user-video-history'
+import { AccountBlocklistModel } from '../models/account/account-blocklist'
+import { ServerBlocklistModel } from '../models/server/server-blocklist'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -91,7 +93,9 @@ async function initDatabaseModels (silent: boolean) {
     VideoImportModel,
     VideoViewModel,
     VideoRedundancyModel,
-    UserVideoHistoryModel
+    UserVideoHistoryModel,
+    AccountBlocklistModel,
+    ServerBlocklistModel
   ])
 
   // Check extensions exist in the database
diff --git a/server/lib/blocklist.ts b/server/lib/blocklist.ts
new file mode 100644 (file)
index 0000000..394c245
--- /dev/null
@@ -0,0 +1,40 @@
+import { sequelizeTypescript } from '../initializers'
+import { AccountBlocklistModel } from '../models/account/account-blocklist'
+import { ServerBlocklistModel } from '../models/server/server-blocklist'
+
+function addAccountInBlocklist (byAccountId: number, targetAccountId: number) {
+  return sequelizeTypescript.transaction(async t => {
+    return AccountBlocklistModel.create({
+      accountId: byAccountId,
+      targetAccountId: targetAccountId
+    }, { transaction: t })
+  })
+}
+
+function addServerInBlocklist (byAccountId: number, targetServerId: number) {
+  return sequelizeTypescript.transaction(async t => {
+    return ServerBlocklistModel.create({
+      accountId: byAccountId,
+      targetServerId
+    }, { transaction: t })
+  })
+}
+
+function removeAccountFromBlocklist (accountBlock: AccountBlocklistModel) {
+  return sequelizeTypescript.transaction(async t => {
+    return accountBlock.destroy({ transaction: t })
+  })
+}
+
+function removeServerFromBlocklist (serverBlock: ServerBlocklistModel) {
+  return sequelizeTypescript.transaction(async t => {
+    return serverBlock.destroy({ transaction: t })
+  })
+}
+
+export {
+  addAccountInBlocklist,
+  addServerInBlocklist,
+  removeAccountFromBlocklist,
+  removeServerFromBlocklist
+}
index 70ba7c3039cd947c3636d65d64418e80f810d111..59bce7520af4c1732988d49b81f6f0347123413d 100644 (file)
@@ -64,10 +64,8 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
     }
 
     const parentCommentThread = idx[childComment.inReplyToCommentId]
-    if (!parentCommentThread) {
-      const msg = `Cannot format video thread tree, parent ${childComment.inReplyToCommentId} not found for child ${childComment.id}`
-      throw new Error(msg)
-    }
+    // Maybe the parent comment was blocked by the admin/user
+    if (!parentCommentThread) continue
 
     parentCommentThread.children.push(childCommentThread)
     idx[childComment.id] = childCommentThread
diff --git a/server/middlewares/validators/blocklist.ts b/server/middlewares/validators/blocklist.ts
new file mode 100644 (file)
index 0000000..9dbd5e5
--- /dev/null
@@ -0,0 +1,94 @@
+import { param, body } from 'express-validator/check'
+import * as express from 'express'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts'
+import { UserModel } from '../../models/account/user'
+import { AccountBlocklistModel } from '../../models/account/account-blocklist'
+import { isHostValid } from '../../helpers/custom-validators/servers'
+import { ServerBlocklistModel } from '../../models/server/server-blocklist'
+
+const blockAccountByAccountValidator = [
+  body('accountName').exists().withMessage('Should have an account name with host'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking blockAccountByAccountValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await isAccountNameWithHostExist(req.body.accountName, res)) return
+
+    return next()
+  }
+]
+
+const unblockAccountByAccountValidator = [
+  param('accountName').exists().withMessage('Should have an account name with host'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking unblockAccountByAccountValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+    if (!await isAccountNameWithHostExist(req.params.accountName, res)) return
+
+    const user = res.locals.oauth.token.User as UserModel
+    const targetAccount = res.locals.account
+    if (!await isUnblockAccountExists(user.Account.id, targetAccount.id, res)) return
+
+    return next()
+  }
+]
+
+const unblockServerByAccountValidator = [
+  param('host').custom(isHostValid).withMessage('Should have an account name with host'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking unblockServerByAccountValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    const user = res.locals.oauth.token.User as UserModel
+    if (!await isUnblockServerExists(user.Account.id, req.params.host, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  blockAccountByAccountValidator,
+  unblockAccountByAccountValidator,
+  unblockServerByAccountValidator
+}
+
+// ---------------------------------------------------------------------------
+
+async function isUnblockAccountExists (accountId: number, targetAccountId: number, res: express.Response) {
+  const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId)
+  if (!accountBlock) {
+    res.status(404)
+       .send({ error: 'Account block entry not found.' })
+       .end()
+
+    return false
+  }
+
+  res.locals.accountBlock = accountBlock
+
+  return true
+}
+
+async function isUnblockServerExists (accountId: number, host: string, res: express.Response) {
+  const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host)
+  if (!serverBlock) {
+    res.status(404)
+       .send({ error: 'Server block entry not found.' })
+       .end()
+
+    return false
+  }
+
+  res.locals.serverBlock = serverBlock
+
+  return true
+}
index 17226614cd46cca8277bde280c8bad9af80c95cc..46c7f0f3ab876b467e41d12d66374b8f1b4c4eda 100644 (file)
@@ -1,4 +1,5 @@
 export * from './account'
+export * from './blocklist'
 export * from './oembed'
 export * from './activitypub'
 export * from './pagination'
@@ -10,3 +11,4 @@ export * from './user-subscriptions'
 export * from './videos'
 export * from './webfinger'
 export * from './search'
+export * from './server'
diff --git a/server/middlewares/validators/server.ts b/server/middlewares/validators/server.ts
new file mode 100644 (file)
index 0000000..a491dfe
--- /dev/null
@@ -0,0 +1,33 @@
+import * as express from 'express'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+import { isHostValid } from '../../helpers/custom-validators/servers'
+import { ServerModel } from '../../models/server/server'
+import { body } from 'express-validator/check'
+
+const serverGetValidator = [
+  body('host').custom(isHostValid).withMessage('Should have a valid host'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking serverGetValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    const server = await ServerModel.loadByHost(req.body.host)
+    if (!server) {
+      return res.status(404)
+         .send({ error: 'Server host not found.' })
+         .end()
+    }
+
+    res.locals.server = server
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  serverGetValidator
+}
index 08dcc268096fc8acbab28f28cecc8613f1d961a6..4c0577d8f6d84255ff013ad4c97c4d5b8d1d4985 100644 (file)
@@ -16,6 +16,8 @@ const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.V
 const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS)
 const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING)
 const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
+const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
+const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
 
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
 const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -31,6 +33,8 @@ const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
 const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS)
 const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS)
 const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS)
+const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
+const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
 
 // ---------------------------------------------------------------------------
 
@@ -48,5 +52,7 @@ export {
   jobsSortValidator,
   videoCommentThreadsSortValidator,
   userSubscriptionsSortValidator,
-  videoChannelsSearchSortValidator
+  videoChannelsSearchSortValidator,
+  accountsBlocklistSortValidator,
+  serversBlocklistSortValidator
 }
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
new file mode 100644 (file)
index 0000000..bacd122
--- /dev/null
@@ -0,0 +1,111 @@
+import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { AccountModel } from './account'
+import { getSort } from '../utils'
+import { AccountBlock } from '../../../shared/models/blocklist'
+
+enum ScopeNames {
+  WITH_ACCOUNTS = 'WITH_ACCOUNTS'
+}
+
+@Scopes({
+  [ScopeNames.WITH_ACCOUNTS]: {
+    include: [
+      {
+        model: () => AccountModel,
+        required: true,
+        as: 'ByAccount'
+      },
+      {
+        model: () => AccountModel,
+        required: true,
+        as: 'AccountBlocked'
+      }
+    ]
+  }
+})
+
+@Table({
+  tableName: 'accountBlocklist',
+  indexes: [
+    {
+      fields: [ 'accountId', 'targetAccountId' ],
+      unique: true
+    },
+    {
+      fields: [ 'targetAccountId' ]
+    }
+  ]
+})
+export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      name: 'accountId',
+      allowNull: false
+    },
+    as: 'ByAccount',
+    onDelete: 'CASCADE'
+  })
+  ByAccount: AccountModel
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  targetAccountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      name: 'targetAccountId',
+      allowNull: false
+    },
+    as: 'AccountBlocked',
+    onDelete: 'CASCADE'
+  })
+  AccountBlocked: AccountModel
+
+  static loadByAccountAndTarget (accountId: number, targetAccountId: number) {
+    const query = {
+      where: {
+        accountId,
+        targetAccountId
+      }
+    }
+
+    return AccountBlocklistModel.findOne(query)
+  }
+
+  static listForApi (accountId: number, start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      where: {
+        accountId
+      }
+    }
+
+    return AccountBlocklistModel
+      .scope([ ScopeNames.WITH_ACCOUNTS ])
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
+  }
+
+  toFormattedJSON (): AccountBlock {
+    return {
+      byAccount: this.ByAccount.toFormattedJSON(),
+      accountBlocked: this.AccountBlocked.toFormattedJSON(),
+      createdAt: this.createdAt
+    }
+  }
+}
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
new file mode 100644 (file)
index 0000000..705ed2c
--- /dev/null
@@ -0,0 +1,121 @@
+import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { AccountModel } from '../account/account'
+import { ServerModel } from './server'
+import { ServerBlock } from '../../../shared/models/blocklist'
+import { getSort } from '../utils'
+
+enum ScopeNames {
+  WITH_ACCOUNT = 'WITH_ACCOUNT',
+  WITH_SERVER = 'WITH_SERVER'
+}
+
+@Scopes({
+  [ScopeNames.WITH_ACCOUNT]: {
+    include: [
+      {
+        model: () => AccountModel,
+        required: true
+      }
+    ]
+  },
+  [ScopeNames.WITH_SERVER]: {
+    include: [
+      {
+        model: () => ServerModel,
+        required: true
+      }
+    ]
+  }
+})
+
+@Table({
+  tableName: 'serverBlocklist',
+  indexes: [
+    {
+      fields: [ 'accountId', 'targetServerId' ],
+      unique: true
+    },
+    {
+      fields: [ 'targetServerId' ]
+    }
+  ]
+})
+export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      name: 'accountId',
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  ByAccount: AccountModel
+
+  @ForeignKey(() => ServerModel)
+  @Column
+  targetServerId: number
+
+  @BelongsTo(() => ServerModel, {
+    foreignKey: {
+      name: 'targetServerId',
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  ServerBlocked: ServerModel
+
+  static loadByAccountAndHost (accountId: number, host: string) {
+    const query = {
+      where: {
+        accountId
+      },
+      include: [
+        {
+          model: ServerModel,
+          where: {
+            host
+          },
+          required: true
+        }
+      ]
+    }
+
+    return ServerBlocklistModel.findOne(query)
+  }
+
+  static listForApi (accountId: number, start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      where: {
+        accountId
+      }
+    }
+
+    return ServerBlocklistModel
+      .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ])
+      .findAndCountAll(query)
+      .then(({ rows, count }) => {
+        return { total: count, data: rows }
+      })
+  }
+
+  toFormattedJSON (): ServerBlock {
+    return {
+      byAccount: this.ByAccount.toFormattedJSON(),
+      serverBlocked: this.ServerBlocked.toFormattedJSON(),
+      createdAt: this.createdAt
+    }
+  }
+}
index ca3b24d51dac3c6c6161398a047fb0d64a4f9fa8..300d7093808c568b76e3520ef8304fafa7fc7522 100644 (file)
@@ -49,4 +49,10 @@ export class ServerModel extends Model<ServerModel> {
 
     return ServerModel.findOne(query)
   }
+
+  toFormattedJSON () {
+    return {
+      host: this.host
+    }
+  }
 }
index e0bf091ad0539a7e981d6d9216d5f0ace9282b89..50c865e758de9f5113a66a5f5554a83b55618d68 100644 (file)
@@ -64,9 +64,27 @@ function createSimilarityAttribute (col: string, value: string) {
   )
 }
 
+function buildBlockedAccountSQL (serverAccountId: number, userAccountId?: number) {
+  const blockerIds = [ serverAccountId ]
+  if (userAccountId) blockerIds.push(userAccountId)
+
+  const blockerIdsString = blockerIds.join(', ')
+
+  const query = 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
+    ' UNION ALL ' +
+    // 'SELECT "accountId" FROM "accountBlocklist" WHERE "targetAccountId" = user.account.id
+    // UNION ALL
+    'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
+    'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
+    'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
+
+  return query
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  buildBlockedAccountSQL,
   SortType,
   getSort,
   getVideoSort,
index f84c1880c6a564fc11c61959514450e25e121e3c..08c6b3ff0f1ee3ba28f22d94d22c7b83dc4eade4 100644 (file)
@@ -1,6 +1,17 @@
 import * as Sequelize from 'sequelize'
 import {
-  AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
+  AllowNull,
+  BeforeDestroy,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  ForeignKey,
+  IFindOptions,
+  Is,
+  Model,
+  Scopes,
+  Table,
   UpdatedAt
 } from 'sequelize-typescript'
 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
@@ -13,9 +24,11 @@ import { AccountModel } from '../account/account'
 import { ActorModel } from '../activitypub/actor'
 import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { getSort, throwIfNotValid } from '../utils'
+import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
 import { VideoModel } from './video'
 import { VideoChannelModel } from './video-channel'
+import { getServerActor } from '../../helpers/utils'
+import { UserModel } from '../account/user'
 
 enum ScopeNames {
   WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -25,18 +38,29 @@ enum ScopeNames {
 }
 
 @Scopes({
-  [ScopeNames.ATTRIBUTES_FOR_API]: {
-    attributes: {
-      include: [
-        [
-          Sequelize.literal(
-            '(SELECT COUNT("replies"."id") ' +
-            'FROM "videoComment" AS "replies" ' +
-            'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
-          ),
-          'totalReplies'
+  [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
+    return {
+      attributes: {
+        include: [
+          [
+            Sequelize.literal(
+              '(' +
+                'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
+                'SELECT COUNT("replies"."id") - (' +
+                  'SELECT COUNT("replies"."id") ' +
+                  'FROM "videoComment" AS "replies" ' +
+                  'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
+                  'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
+                ')' +
+                'FROM "videoComment" AS "replies" ' +
+                'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
+                'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
+              ')'
+            ),
+            'totalReplies'
+          ]
         ]
-      ]
+      }
     }
   },
   [ScopeNames.WITH_ACCOUNT]: {
@@ -267,26 +291,47 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
   }
 
-  static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
+  static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
+    const serverActor = await getServerActor()
+    const serverAccountId = serverActor.Account.id
+    const userAccountId = user.Account.id
+
     const query = {
       offset: start,
       limit: count,
       order: getSort(sort),
       where: {
         videoId,
-        inReplyToCommentId: null
+        inReplyToCommentId: null,
+        accountId: {
+          [Sequelize.Op.notIn]: Sequelize.literal(
+            '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
+          )
+        }
       }
     }
 
+    // FIXME: typings
+    const scopes: any[] = [
+      ScopeNames.WITH_ACCOUNT,
+      {
+        method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
+      }
+    ]
+
     return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
+      .scope(scopes)
       .findAndCountAll(query)
       .then(({ rows, count }) => {
         return { total: count, data: rows }
       })
   }
 
-  static listThreadCommentsForApi (videoId: number, threadId: number) {
+  static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
+    const serverActor = await getServerActor()
+    const serverAccountId = serverActor.Account.id
+    const userAccountId = user.Account.id
+
     const query = {
       order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
       where: {
@@ -294,12 +339,24 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
         [ Sequelize.Op.or ]: [
           { id: threadId },
           { originCommentId: threadId }
-        ]
+        ],
+        accountId: {
+          [Sequelize.Op.notIn]: Sequelize.literal(
+            '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
+          )
+        }
       }
     }
 
+    const scopes: any[] = [
+      ScopeNames.WITH_ACCOUNT,
+      {
+        method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
+      }
+    ]
+
     return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
+      .scope(scopes)
       .findAndCountAll(query)
       .then(({ rows, count }) => {
         return { total: count, data: rows }
index 4f3f75613d101b803aaa355a471a40f973defce6..eab99cba7eeaa575592622512b99adbe007a4b71 100644 (file)
@@ -27,7 +27,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { VideoPrivacy, VideoState } from '../../../shared'
+import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
 import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
 import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -70,7 +70,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate'
 import { ActorModel } from '../activitypub/actor'
 import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
+import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
 import { TagModel } from './tag'
 import { VideoAbuseModel } from './video-abuse'
 import { VideoChannelModel } from './video-channel'
@@ -93,6 +93,7 @@ import {
 } from './video-format-utils'
 import * as validator from 'validator'
 import { UserVideoHistoryModel } from '../account/user-video-history'
+import { UserModel } from '../account/user'
 
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -138,6 +139,7 @@ type ForAPIOptions = {
 }
 
 type AvailableForListIDsOptions = {
+  serverAccountId: number
   actorId: number
   includeLocalVideos: boolean
   filter?: VideoFilter
@@ -151,6 +153,7 @@ type AvailableForListIDsOptions = {
   accountId?: number
   videoChannelId?: number
   trendingDays?: number
+  user?: UserModel
 }
 
 @Scopes({
@@ -235,6 +238,15 @@ type AvailableForListIDsOptions = {
               )
             }
           ]
+        },
+        channelId: {
+          [ Sequelize.Op.notIn ]: Sequelize.literal(
+            '(' +
+              'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
+                buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
+              ')' +
+            ')'
+          )
         }
       },
       include: []
@@ -975,10 +987,10 @@ export class VideoModel extends Model<VideoModel> {
     videoChannelId?: number,
     actorId?: number
     trendingDays?: number,
-    userId?: number
+    user?: UserModel
   }, countVideos = true) {
-    if (options.filter && options.filter === 'all-local' && !options.userId) {
-      throw new Error('Try to filter all-local but no userId is provided')
+    if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
+      throw new Error('Try to filter all-local but no user has not the see all videos right')
     }
 
     const query: IFindOptions<VideoModel> = {
@@ -994,11 +1006,14 @@ export class VideoModel extends Model<VideoModel> {
       query.group = 'VideoModel.id'
     }
 
+    const serverActor = await getServerActor()
+
     // actorId === null has a meaning, so just check undefined
-    const actorId = options.actorId !== undefined ? options.actorId : (await getServerActor()).id
+    const actorId = options.actorId !== undefined ? options.actorId : serverActor.id
 
     const queryOptions = {
       actorId,
+      serverAccountId: serverActor.Account.id,
       nsfw: options.nsfw,
       categoryOneOf: options.categoryOneOf,
       licenceOneOf: options.licenceOneOf,
@@ -1010,7 +1025,7 @@ export class VideoModel extends Model<VideoModel> {
       accountId: options.accountId,
       videoChannelId: options.videoChannelId,
       includeLocalVideos: options.includeLocalVideos,
-      userId: options.userId,
+      user: options.user,
       trendingDays
     }
 
@@ -1033,7 +1048,7 @@ export class VideoModel extends Model<VideoModel> {
     tagsAllOf?: string[]
     durationMin?: number // seconds
     durationMax?: number // seconds
-    userId?: number,
+    user?: UserModel,
     filter?: VideoFilter
   }) {
     const whereAnd = []
@@ -1104,6 +1119,7 @@ export class VideoModel extends Model<VideoModel> {
     const serverActor = await getServerActor()
     const queryOptions = {
       actorId: serverActor.id,
+      serverAccountId: serverActor.Account.id,
       includeLocalVideos: options.includeLocalVideos,
       nsfw: options.nsfw,
       categoryOneOf: options.categoryOneOf,
@@ -1111,7 +1127,7 @@ export class VideoModel extends Model<VideoModel> {
       languageOneOf: options.languageOneOf,
       tagsOneOf: options.tagsOneOf,
       tagsAllOf: options.tagsAllOf,
-      userId: options.userId,
+      user: options.user,
       filter: options.filter
     }
 
@@ -1287,7 +1303,7 @@ export class VideoModel extends Model<VideoModel> {
 
   private static async getAvailableForApi (
     query: IFindOptions<VideoModel>,
-    options: AvailableForListIDsOptions & { userId?: number},
+    options: AvailableForListIDsOptions,
     countVideos = true
   ) {
     const idsScope = {
@@ -1320,8 +1336,8 @@ export class VideoModel extends Model<VideoModel> {
       }
     ]
 
-    if (options.userId) {
-      apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] })
+    if (options.user) {
+      apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
     }
 
     const secondQuery = {
diff --git a/server/tests/api/check-params/blocklist.ts b/server/tests/api/check-params/blocklist.ts
new file mode 100644 (file)
index 0000000..8117c46
--- /dev/null
@@ -0,0 +1,222 @@
+/* tslint:disable:no-unused-expression */
+
+import 'mocha'
+
+import {
+  createUser,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  flushTests,
+  killallServers,
+  makeDeleteRequest,
+  makeGetRequest,
+  makePostBodyRequest,
+  ServerInfo,
+  setAccessTokensToServers
+} from '../../utils'
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params'
+
+describe('Test blocklist API validators', function () {
+  let servers: ServerInfo[]
+  let server: ServerInfo
+
+  before(async function () {
+    this.timeout(60000)
+
+    await flushTests()
+
+    servers = await flushAndRunMultipleServers(2)
+    await setAccessTokensToServers(servers)
+
+    server = servers[0]
+
+    const user = { username: 'user1', password: 'password' }
+    await createUser(server.url, server.accessToken, user.username, user.password)
+
+    await doubleFollow(servers[0], servers[1])
+  })
+
+  // ---------------------------------------------------------------
+
+  describe('When managing user blocklist', function () {
+    const path = '/api/v1/users/me/blocklist/accounts'
+
+    describe('When managing user accounts blocklist', function () {
+
+      describe('When listing blocked accounts', function () {
+        it('Should fail with an unauthenticated user', async function () {
+          await makeGetRequest({
+            url: server.url,
+            path,
+            statusCodeExpected: 401
+          })
+        })
+
+        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 blocking an account', function () {
+        it('Should fail with an unauthenticated user', async function () {
+          await makePostBodyRequest({
+            url: server.url,
+            path,
+            fields: { accountName: 'user1' },
+            statusCodeExpected: 401
+          })
+        })
+
+        it('Should fail with an unknown account', async function () {
+          await makePostBodyRequest({
+            url: server.url,
+            token: server.accessToken,
+            path,
+            fields: { accountName: 'user2' },
+            statusCodeExpected: 404
+          })
+        })
+
+        it('Should succeed with the correct params', async function () {
+          await makePostBodyRequest({
+            url: server.url,
+            token: server.accessToken,
+            path,
+            fields: { accountName: 'user1' },
+            statusCodeExpected: 204
+          })
+        })
+      })
+
+      describe('When unblocking an account', function () {
+        it('Should fail with an unauthenticated user', async function () {
+          await makeDeleteRequest({
+            url: server.url,
+            path: path + '/user1',
+            statusCodeExpected: 401
+          })
+        })
+
+        it('Should fail with an unknown account block', async function () {
+          await makeDeleteRequest({
+            url: server.url,
+            path: path + '/user2',
+            token: server.accessToken,
+            statusCodeExpected: 404
+          })
+        })
+
+        it('Should succeed with the correct params', async function () {
+          await makeDeleteRequest({
+            url: server.url,
+            path: path + '/user1',
+            token: server.accessToken,
+            statusCodeExpected: 204
+          })
+        })
+      })
+    })
+
+    describe('When managing user servers blocklist', function () {
+      const path = '/api/v1/users/me/blocklist/servers'
+
+      describe('When listing blocked servers', function () {
+        it('Should fail with an unauthenticated user', async function () {
+          await makeGetRequest({
+            url: server.url,
+            path,
+            statusCodeExpected: 401
+          })
+        })
+
+        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 blocking a server', function () {
+        it('Should fail with an unauthenticated user', async function () {
+          await makePostBodyRequest({
+            url: server.url,
+            path,
+            fields: { host: 'localhost:9002' },
+            statusCodeExpected: 401
+          })
+        })
+
+        it('Should fail with an unknown server', async function () {
+          await makePostBodyRequest({
+            url: server.url,
+            token: server.accessToken,
+            path,
+            fields: { host: 'localhost:9003' },
+            statusCodeExpected: 404
+          })
+        })
+
+        it('Should succeed with the correct params', async function () {
+          await makePostBodyRequest({
+            url: server.url,
+            token: server.accessToken,
+            path,
+            fields: { host: 'localhost:9002' },
+            statusCodeExpected: 204
+          })
+        })
+      })
+
+      describe('When unblocking a server', function () {
+        it('Should fail with an unauthenticated user', async function () {
+          await makeDeleteRequest({
+            url: server.url,
+            path: path + '/localhost:9002',
+            statusCodeExpected: 401
+          })
+        })
+
+        it('Should fail with an unknown server block', async function () {
+          await makeDeleteRequest({
+            url: server.url,
+            path: path + '/localhost:9003',
+            token: server.accessToken,
+            statusCodeExpected: 404
+          })
+        })
+
+        it('Should succeed with the correct params', async function () {
+          await makeDeleteRequest({
+            url: server.url,
+            path: path + '/localhost:9002',
+            token: server.accessToken,
+            statusCodeExpected: 204
+          })
+        })
+      })
+    })
+  })
+
+  after(async function () {
+    killallServers(servers)
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index bfc550ae50368fb78d29ed5044df7e8286ad4071..877ceb0a7671f73fa2f5c1fa010c6c2818693128 100644 (file)
@@ -1,5 +1,6 @@
 // Order of the tests we want to execute
 import './accounts'
+import './blocklist'
 import './config'
 import './follows'
 import './jobs'
diff --git a/server/tests/api/users/account-blocklist.ts b/server/tests/api/users/account-blocklist.ts
new file mode 100644 (file)
index 0000000..00ad514
--- /dev/null
@@ -0,0 +1,294 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { AccountBlock, ServerBlock, Video } from '../../../../shared/index'
+import {
+  createUser,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  flushTests,
+  killallServers,
+  ServerInfo,
+  uploadVideo,
+  userLogin
+} from '../../utils/index'
+import { setAccessTokensToServers } from '../../utils/users/login'
+import { getVideosListWithToken } from '../../utils/videos/videos'
+import {
+  addVideoCommentReply,
+  addVideoCommentThread,
+  getVideoCommentThreads,
+  getVideoThreadComments
+} from '../../utils/videos/video-comments'
+import { waitJobs } from '../../utils/server/jobs'
+import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
+import {
+  addAccountToAccountBlocklist,
+  addServerToAccountBlocklist,
+  getAccountBlocklistByAccount, getServerBlocklistByAccount,
+  removeAccountFromAccountBlocklist,
+  removeServerFromAccountBlocklist
+} from '../../utils/users/blocklist'
+
+const expect = chai.expect
+
+async function checkAllVideos (url: string, token: string) {
+  const res = await getVideosListWithToken(url, token)
+
+  expect(res.body.data).to.have.lengthOf(4)
+}
+
+async function checkAllComments (url: string, token: string, videoUUID: string) {
+  const resThreads = await getVideoCommentThreads(url, videoUUID, 0, 5, '-createdAt', token)
+
+  const threads: VideoComment[] = resThreads.body.data
+  expect(threads).to.have.lengthOf(2)
+
+  for (const thread of threads) {
+    const res = await getVideoThreadComments(url, videoUUID, thread.id, token)
+
+    const tree: VideoCommentThreadTree = res.body
+    expect(tree.children).to.have.lengthOf(1)
+  }
+}
+
+describe('Test accounts blocklist', function () {
+  let servers: ServerInfo[]
+  let videoUUID1: string
+  let videoUUID2: string
+  let userToken1: string
+  let userToken2: string
+
+  before(async function () {
+    this.timeout(60000)
+
+    await flushTests()
+
+    servers = await flushAndRunMultipleServers(2)
+    await setAccessTokensToServers(servers)
+
+    {
+      const user = { username: 'user1', password: 'password' }
+      await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
+
+      userToken1 = await userLogin(servers[0], user)
+      await uploadVideo(servers[0].url, userToken1, { name: 'video user 1' })
+    }
+
+    {
+      const user = { username: 'user2', password: 'password' }
+      await createUser(servers[1].url, servers[1].accessToken, user.username, user.password)
+
+      userToken2 = await userLogin(servers[1], user)
+      await uploadVideo(servers[1].url, userToken2, { name: 'video user 2' })
+    }
+
+    {
+      const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video server 1' })
+      videoUUID1 = res.body.video.uuid
+    }
+
+    {
+      const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video server 2' })
+      videoUUID2 = res.body.video.uuid
+    }
+
+    await doubleFollow(servers[0], servers[1])
+
+    {
+      const resComment = await addVideoCommentThread(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1, 'comment root 1')
+      const resReply = await addVideoCommentReply(servers[ 0 ].url, userToken1, videoUUID1, resComment.body.comment.id, 'comment user 1')
+      await addVideoCommentReply(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1, resReply.body.comment.id, 'comment root 1')
+    }
+
+    {
+      const resComment = await addVideoCommentThread(servers[ 0 ].url, userToken1, videoUUID1, 'comment user 1')
+      await addVideoCommentReply(servers[ 0 ].url, servers[ 0 ].accessToken, videoUUID1, resComment.body.comment.id, 'comment root 1')
+    }
+
+    await waitJobs(servers)
+  })
+
+  describe('When managing account blocklist', function () {
+    it('Should list all videos', function () {
+      return checkAllVideos(servers[0].url, servers[0].accessToken)
+    })
+
+    it('Should list the comments', function () {
+      return checkAllComments(servers[0].url, servers[0].accessToken, videoUUID1)
+    })
+
+    it('Should block a remote account', async function () {
+      await addAccountToAccountBlocklist(servers[0].url, servers[0].accessToken, 'user2@localhost:9002')
+    })
+
+    it('Should hide its videos', async function () {
+      const res = await getVideosListWithToken(servers[0].url, servers[0].accessToken)
+
+      const videos: Video[] = res.body.data
+      expect(videos).to.have.lengthOf(3)
+
+      const v = videos.find(v => v.name === 'video user 2')
+      expect(v).to.be.undefined
+    })
+
+    it('Should block a local account', async function () {
+      await addAccountToAccountBlocklist(servers[0].url, servers[0].accessToken, 'user1')
+    })
+
+    it('Should hide its videos', async function () {
+      const res = await getVideosListWithToken(servers[0].url, servers[0].accessToken)
+
+      const videos: Video[] = res.body.data
+      expect(videos).to.have.lengthOf(2)
+
+      const v = videos.find(v => v.name === 'video user 1')
+      expect(v).to.be.undefined
+    })
+
+    it('Should hide its comments', async function () {
+      const resThreads = await getVideoCommentThreads(servers[0].url, videoUUID1, 0, 5, '-createdAt', servers[0].accessToken)
+
+      const threads: VideoComment[] = resThreads.body.data
+      expect(threads).to.have.lengthOf(1)
+      expect(threads[0].totalReplies).to.equal(0)
+
+      const t = threads.find(t => t.text === 'comment user 1')
+      expect(t).to.be.undefined
+
+      for (const thread of threads) {
+        const res = await getVideoThreadComments(servers[0].url, videoUUID1, thread.id, servers[0].accessToken)
+
+        const tree: VideoCommentThreadTree = res.body
+        expect(tree.children).to.have.lengthOf(0)
+      }
+    })
+
+    it('Should list all the videos with another user', async function () {
+      return checkAllVideos(servers[0].url, userToken1)
+    })
+
+    it('Should list all the comments with another user', async function () {
+      return checkAllComments(servers[0].url, userToken1, videoUUID1)
+    })
+
+    it('Should list blocked accounts', async function () {
+      {
+        const res = await getAccountBlocklistByAccount(servers[ 0 ].url, servers[ 0 ].accessToken, 0, 1, 'createdAt')
+        const blocks: AccountBlock[] = res.body.data
+
+        expect(res.body.total).to.equal(2)
+
+        const block = blocks[0]
+        expect(block.byAccount.displayName).to.equal('root')
+        expect(block.byAccount.name).to.equal('root')
+        expect(block.accountBlocked.displayName).to.equal('user2')
+        expect(block.accountBlocked.name).to.equal('user2')
+        expect(block.accountBlocked.host).to.equal('localhost:9002')
+      }
+
+      {
+        const res = await getAccountBlocklistByAccount(servers[ 0 ].url, servers[ 0 ].accessToken, 1, 2, 'createdAt')
+        const blocks: AccountBlock[] = res.body.data
+
+        expect(res.body.total).to.equal(2)
+
+        const block = blocks[0]
+        expect(block.byAccount.displayName).to.equal('root')
+        expect(block.byAccount.name).to.equal('root')
+        expect(block.accountBlocked.displayName).to.equal('user1')
+        expect(block.accountBlocked.name).to.equal('user1')
+        expect(block.accountBlocked.host).to.equal('localhost:9001')
+      }
+    })
+
+    it('Should unblock the remote account', async function () {
+      await removeAccountFromAccountBlocklist(servers[0].url, servers[0].accessToken, 'user2@localhost:9002')
+    })
+
+    it('Should display its videos', async function () {
+      const res = await getVideosListWithToken(servers[0].url, servers[0].accessToken)
+
+      const videos: Video[] = res.body.data
+      expect(videos).to.have.lengthOf(3)
+
+      const v = videos.find(v => v.name === 'video user 2')
+      expect(v).not.to.be.undefined
+    })
+
+    it('Should unblock the local account', async function () {
+      await removeAccountFromAccountBlocklist(servers[0].url, servers[0].accessToken, 'user1')
+    })
+
+    it('Should display its comments', function () {
+      return checkAllComments(servers[0].url, servers[0].accessToken, videoUUID1)
+    })
+  })
+
+  describe('When managing server blocklist', function () {
+    it('Should list all videos', function () {
+      return checkAllVideos(servers[0].url, servers[0].accessToken)
+    })
+
+    it('Should list the comments', function () {
+      return checkAllComments(servers[0].url, servers[0].accessToken, videoUUID1)
+    })
+
+    it('Should block a remote server', async function () {
+      await addServerToAccountBlocklist(servers[0].url, servers[0].accessToken, 'localhost:9002')
+    })
+
+    it('Should hide its videos', async function () {
+      const res = await getVideosListWithToken(servers[0].url, servers[0].accessToken)
+
+      const videos: Video[] = res.body.data
+      expect(videos).to.have.lengthOf(2)
+
+      const v1 = videos.find(v => v.name === 'video user 2')
+      const v2 = videos.find(v => v.name === 'video server 2')
+
+      expect(v1).to.be.undefined
+      expect(v2).to.be.undefined
+    })
+
+    it('Should list all the videos with another user', async function () {
+      return checkAllVideos(servers[0].url, userToken1)
+    })
+
+    it('Should hide its comments')
+
+    it('Should list blocked servers', async function () {
+      const res = await getServerBlocklistByAccount(servers[ 0 ].url, servers[ 0 ].accessToken, 0, 1, 'createdAt')
+      const blocks: ServerBlock[] = res.body.data
+
+      expect(res.body.total).to.equal(1)
+
+      const block = blocks[0]
+      expect(block.byAccount.displayName).to.equal('root')
+      expect(block.byAccount.name).to.equal('root')
+      expect(block.serverBlocked.host).to.equal('localhost:9002')
+    })
+
+    it('Should unblock the remote server', async function () {
+      await removeServerFromAccountBlocklist(servers[0].url, servers[0].accessToken, 'localhost:9002')
+    })
+
+    it('Should display its videos', function () {
+      return checkAllVideos(servers[0].url, servers[0].accessToken)
+    })
+
+    it('Should display its comments', function () {
+      return checkAllComments(servers[0].url, servers[0].accessToken, videoUUID1)
+    })
+  })
+
+  after(async function () {
+    killallServers(servers)
+
+    // Keep the logs if the test failed
+    if (this[ 'ok' ]) {
+      await flushTests()
+    }
+  })
+})
index 27a529edaf36b178ffc7f5d03ce7992cd4e00357..5796540f7c3e45b4d54d44fee95c099cd30ecdff 100644 (file)
@@ -37,9 +37,7 @@ function makeDeleteRequest (options: {
 
   if (options.token) req.set('Authorization', 'Bearer ' + options.token)
 
-  return req
-    .expect('Content-Type', /json/)
-    .expect(options.statusCodeExpected)
+  return req.expect(options.statusCodeExpected)
 }
 
 function makeUploadRequest (options: {
diff --git a/server/tests/utils/users/blocklist.ts b/server/tests/utils/users/blocklist.ts
new file mode 100644 (file)
index 0000000..47b3154
--- /dev/null
@@ -0,0 +1,103 @@
+/* tslint:disable:no-unused-expression */
+
+import { makeDeleteRequest, makePostBodyRequest } from '../index'
+import { makeGetRequest } from '../requests/requests'
+
+function getAccountBlocklistByAccount (
+  url: string,
+  token: string,
+  start: number,
+  count: number,
+  sort = '-createdAt',
+  statusCodeExpected = 200
+) {
+  const path = '/api/v1/users/me/blocklist/accounts'
+
+  return makeGetRequest({
+    url,
+    token,
+    query: { start, count, sort },
+    path,
+    statusCodeExpected
+  })
+}
+
+function addAccountToAccountBlocklist (url: string, token: string, accountToBlock: string, statusCodeExpected = 204) {
+  const path = '/api/v1/users/me/blocklist/accounts'
+
+  return makePostBodyRequest({
+    url,
+    path,
+    token,
+    fields: {
+      accountName: accountToBlock
+    },
+    statusCodeExpected
+  })
+}
+
+function removeAccountFromAccountBlocklist (url: string, token: string, accountToUnblock: string, statusCodeExpected = 204) {
+  const path = '/api/v1/users/me/blocklist/accounts/' + accountToUnblock
+
+  return makeDeleteRequest({
+    url,
+    path,
+    token,
+    statusCodeExpected
+  })
+}
+
+function getServerBlocklistByAccount (
+  url: string,
+  token: string,
+  start: number,
+  count: number,
+  sort = '-createdAt',
+  statusCodeExpected = 200
+) {
+  const path = '/api/v1/users/me/blocklist/servers'
+
+  return makeGetRequest({
+    url,
+    token,
+    query: { start, count, sort },
+    path,
+    statusCodeExpected
+  })
+}
+
+function addServerToAccountBlocklist (url: string, token: string, serverToBlock: string, statusCodeExpected = 204) {
+  const path = '/api/v1/users/me/blocklist/servers'
+
+  return makePostBodyRequest({
+    url,
+    path,
+    token,
+    fields: {
+      host: serverToBlock
+    },
+    statusCodeExpected
+  })
+}
+
+function removeServerFromAccountBlocklist (url: string, token: string, serverToBlock: string, statusCodeExpected = 204) {
+  const path = '/api/v1/users/me/blocklist/servers/' + serverToBlock
+
+  return makeDeleteRequest({
+    url,
+    path,
+    token,
+    statusCodeExpected
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  getAccountBlocklistByAccount,
+  addAccountToAccountBlocklist,
+  removeAccountFromAccountBlocklist,
+  getServerBlocklistByAccount,
+  addServerToAccountBlocklist,
+  removeServerFromAccountBlocklist
+}
index 1b9ee452eeb1d71a12dd41de25b4a657096c3fda..7d4cae3647ec29f7c8c86ec8c7d58f3895cf3e70 100644 (file)
@@ -1,7 +1,7 @@
 import * as request from 'supertest'
 import { makeDeleteRequest } from '../'
 
-function getVideoCommentThreads (url: string, videoId: number | string, start: number, count: number, sort?: string) {
+function getVideoCommentThreads (url: string, videoId: number | string, start: number, count: number, sort?: string, token?: string) {
   const path = '/api/v1/videos/' + videoId + '/comment-threads'
 
   const req = request(url)
@@ -10,20 +10,24 @@ function getVideoCommentThreads (url: string, videoId: number | string, start: n
     .query({ count: count })
 
   if (sort) req.query({ sort })
+  if (token) req.set('Authorization', 'Bearer ' + token)
 
   return req.set('Accept', 'application/json')
     .expect(200)
     .expect('Content-Type', /json/)
 }
 
-function getVideoThreadComments (url: string, videoId: number | string, threadId: number) {
+function getVideoThreadComments (url: string, videoId: number | string, threadId: number, token?: string) {
   const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId
 
-  return request(url)
+  const req = request(url)
     .get(path)
     .set('Accept', 'application/json')
-    .expect(200)
-    .expect('Content-Type', /json/)
+
+  if (token) req.set('Authorization', 'Bearer ' + token)
+
+  return req.expect(200)
+            .expect('Content-Type', /json/)
 }
 
 function addVideoCommentThread (url: string, token: string, videoId: number | string, text: string, expectedStatus = 200) {
diff --git a/shared/models/blocklist/account-block.model.ts b/shared/models/blocklist/account-block.model.ts
new file mode 100644 (file)
index 0000000..d6f8840
--- /dev/null
@@ -0,0 +1,7 @@
+import { Account } from '../actors'
+
+export interface AccountBlock {
+  byAccount: Account
+  accountBlocked: Account
+  createdAt: Date | string
+}
diff --git a/shared/models/blocklist/index.ts b/shared/models/blocklist/index.ts
new file mode 100644 (file)
index 0000000..fc78732
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './account-block.model'
+export * from './server-block.model'
diff --git a/shared/models/blocklist/server-block.model.ts b/shared/models/blocklist/server-block.model.ts
new file mode 100644 (file)
index 0000000..efba672
--- /dev/null
@@ -0,0 +1,9 @@
+import { Account } from '../actors'
+
+export interface ServerBlock {
+  byAccount: Account
+  serverBlocked: {
+    host: string
+  }
+  createdAt: Date | string
+}
index e61d6cbdc791fa5d37c59f94134ad14c4c2e3e0c..062533834e11de3e39c95eb311d40e35c2b549b5 100644 (file)
@@ -1,6 +1,7 @@
 export * from './activitypub'
 export * from './actors'
 export * from './avatars'
+export * from './blocklist'
 export * from './redundancy'
 export * from './users'
 export * from './videos'