diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 8e3f60010..86ef2aed1 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -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)) diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index a8a6cfb08..534305ba6 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts @@ -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) diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 4f8137c03..9fcb8077f 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -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', diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 591ec6b25..ebe668110 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -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 index 000000000..e955ffde9 --- /dev/null +++ b/server/controllers/api/users/my-blocklist.ts @@ -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() +} diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index c84d1be58..9bf3c5fd8 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -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)) diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index 4f2b4faee..3875c8f79 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts @@ -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 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 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, diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 6a73e13d0..664154406 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -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)) diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index 39afb4e7b..049c3f8bc 100644 --- a/server/helpers/utils.ts +++ b/server/helpers/utils.ts @@ -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) { diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 49ee13c10..cf00da2c7 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -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 = { diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 482c03b31..dd5b9bf67 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -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 index 000000000..394c24537 --- /dev/null +++ b/server/lib/blocklist.ts @@ -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 +} diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts index 70ba7c303..59bce7520 100644 --- a/server/lib/video-comment.ts +++ b/server/lib/video-comment.ts @@ -64,10 +64,8 @@ function buildFormattedCommentTree (resultList: ResultList): } 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 index 000000000..9dbd5e512 --- /dev/null +++ b/server/middlewares/validators/blocklist.ts @@ -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 +} diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index 17226614c..46c7f0f3a 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -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 index 000000000..a491dfeb3 --- /dev/null +++ b/server/middlewares/validators/server.ts @@ -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 +} diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 08dcc2680..4c0577d8f 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -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 index 000000000..bacd122e8 --- /dev/null +++ b/server/models/account/account-blocklist.ts @@ -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 { + + @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 index 000000000..705ed2c6b --- /dev/null +++ b/server/models/server/server-blocklist.ts @@ -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 { + + @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 + } + } +} diff --git a/server/models/server/server.ts b/server/models/server/server.ts index ca3b24d51..300d70938 100644 --- a/server/models/server/server.ts +++ b/server/models/server/server.ts @@ -49,4 +49,10 @@ export class ServerModel extends Model { return ServerModel.findOne(query) } + + toFormattedJSON () { + return { + host: this.host + } + } } diff --git a/server/models/utils.ts b/server/models/utils.ts index e0bf091ad..50c865e75 100644 --- a/server/models/utils.ts +++ b/server/models/utils.ts @@ -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, diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index f84c1880c..08c6b3ff0 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -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 { 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 { [ 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 } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 4f3f75613..eab99cba7 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -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 { 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 = { @@ -994,11 +1006,14 @@ export class VideoModel extends Model { 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 { 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 { 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 { 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 { 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 { private static async getAvailableForApi ( query: IFindOptions, - options: AvailableForListIDsOptions & { userId?: number}, + options: AvailableForListIDsOptions, countVideos = true ) { const idsScope = { @@ -1320,8 +1336,8 @@ export class VideoModel extends Model { } ] - 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 index 000000000..8117c46a6 --- /dev/null +++ b/server/tests/api/check-params/blocklist.ts @@ -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() + } + }) +}) diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index bfc550ae5..877ceb0a7 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -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 index 000000000..00ad51461 --- /dev/null +++ b/server/tests/api/users/account-blocklist.ts @@ -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() + } + }) +}) diff --git a/server/tests/utils/requests/requests.ts b/server/tests/utils/requests/requests.ts index 27a529eda..5796540f7 100644 --- a/server/tests/utils/requests/requests.ts +++ b/server/tests/utils/requests/requests.ts @@ -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 index 000000000..47b315480 --- /dev/null +++ b/server/tests/utils/users/blocklist.ts @@ -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 +} diff --git a/server/tests/utils/videos/video-comments.ts b/server/tests/utils/videos/video-comments.ts index 1b9ee452e..7d4cae364 100644 --- a/server/tests/utils/videos/video-comments.ts +++ b/server/tests/utils/videos/video-comments.ts @@ -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 index 000000000..d6f8840c5 --- /dev/null +++ b/shared/models/blocklist/account-block.model.ts @@ -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 index 000000000..fc7873270 --- /dev/null +++ b/shared/models/blocklist/index.ts @@ -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 index 000000000..efba672bd --- /dev/null +++ b/shared/models/blocklist/server-block.model.ts @@ -0,0 +1,9 @@ +import { Account } from '../actors' + +export interface ServerBlock { + byAccount: Account + serverBlocked: { + host: string + } + createdAt: Date | string +} diff --git a/shared/models/index.ts b/shared/models/index.ts index e61d6cbdc..062533834 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts @@ -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'