diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 245edfd58..144545250 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html @@ -19,10 +19,8 @@ > Banned - Muted - Instance muted - Muted by your instance - Instance muted by your instance + +
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss index cdd00487b..5043b98c4 100644 --- a/client/src/app/+accounts/accounts.component.scss +++ b/client/src/app/+accounts/accounts.component.scss @@ -30,16 +30,10 @@ } } -my-user-moderation-dropdown, -.badge { - @include margin-left(10px); +my-user-moderation-dropdown { + margin: 0 10px; - position: relative; - top: 3px; -} - -.badge { - font-size: 13px; + height: fit-content; } .copy-button { @@ -64,6 +58,10 @@ my-user-moderation-dropdown, @include avatar-row-responsive(var(--myImgMargin), var(--myGreyFontSize)); } +.actor-display-name { + align-items: center; +} + .description { grid-column: 1 / 3; max-width: 1000px; diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 3cb117fcc..460f1dbf9 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts @@ -12,7 +12,7 @@ import { VideoChannelService, VideoService } from '@app/shared/shared-main' -import { AccountReportComponent } from '@app/shared/shared-moderation' +import { AccountReportComponent, BlocklistService } from '@app/shared/shared-moderation' import { HttpStatusCode, User, UserRight } from '@shared/models' @Component({ @@ -52,6 +52,7 @@ export class AccountsComponent implements OnInit, OnDestroy { private authService: AuthService, private videoService: VideoService, private markdown: MarkdownService, + private blocklist: BlocklistService, private screenService: ScreenService ) { } @@ -159,6 +160,7 @@ export class AccountsComponent implements OnInit, OnDestroy { this.updateModerationActions() this.loadUserIfNeeded(account) this.loadAccountVideosCount() + this.loadAccountBlockStatus() } private showReportModal () { @@ -217,4 +219,9 @@ export class AccountsComponent implements OnInit, OnDestroy { this.accountVideosCount = res.total }) } + + private loadAccountBlockStatus () { + this.blocklist.getStatus({ accounts: [ this.account.nameWithHostForced ], hosts: [ this.account.host ] }) + .subscribe(status => this.account.updateBlockStatus(status)) + } } diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index 064fbb6f5..aec2e373c 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html @@ -23,14 +23,16 @@
OWNER ACCOUNT
- +
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index 272fc41d9..ebb991f4e 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts @@ -4,7 +4,8 @@ import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { AuthService, MarkdownService, Notifier, RestExtractor, ScreenService } from '@app/core' -import { ListOverflowItem, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' +import { Account, ListOverflowItem, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' +import { BlocklistService } from '@app/shared/shared-moderation' import { SupportModalComponent } from '@app/shared/shared-support-modal' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { HttpStatusCode } from '@shared/models' @@ -18,6 +19,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { @ViewChild('supportModal') supportModal: SupportModalComponent videoChannel: VideoChannel + ownerAccount: Account hotkeys: Hotkey[] links: ListOverflowItem[] = [] isChannelManageable = false @@ -38,7 +40,8 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { private restExtractor: RestExtractor, private hotkeysService: HotkeysService, private screenService: ScreenService, - private markdown: MarkdownService + private markdown: MarkdownService, + private blocklist: BlocklistService ) { } ngOnInit () { @@ -58,8 +61,10 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { // After the markdown renderer to avoid layout changes this.videoChannel = videoChannel + this.ownerAccount = new Account(this.videoChannel.ownerAccount) this.loadChannelVideosCount() + this.loadOwnerBlockStatus() }) this.hotkeys = [ @@ -125,4 +130,9 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { sort: '-publishedAt' }).subscribe(res => this.channelVideosCount = res.total) } + + private loadOwnerBlockStatus () { + this.blocklist.getStatus({ accounts: [ this.ownerAccount.nameWithHostForced ], hosts: [ this.ownerAccount.host ] }) + .subscribe(status => this.ownerAccount.updateBlockStatus(status)) + } } diff --git a/client/src/app/+video-channels/video-channels.module.ts b/client/src/app/+video-channels/video-channels.module.ts index 35c39cc2e..76aaecf83 100644 --- a/client/src/app/+video-channels/video-channels.module.ts +++ b/client/src/app/+video-channels/video-channels.module.ts @@ -2,15 +2,16 @@ import { NgModule } from '@angular/core' import { SharedFormModule } from '@app/shared/shared-forms' import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedMainModule } from '@app/shared/shared-main' +import { SharedModerationModule } from '@app/shared/shared-moderation' import { SharedSupportModal } from '@app/shared/shared-support-modal' import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' +import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component' import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' import { VideoChannelsRoutingModule } from './video-channels-routing.module' import { VideoChannelsComponent } from './video-channels.component' -import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' @NgModule({ imports: [ @@ -23,7 +24,8 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto SharedUserSubscriptionModule, SharedGlobalIconModule, SharedSupportModal, - SharedActorImageModule + SharedActorImageModule, + SharedModerationModule ], declarations: [ diff --git a/client/src/app/shared/shared-main/account/account.model.ts b/client/src/app/shared/shared-main/account/account.model.ts index 92606e7fa..8b78d01a6 100644 --- a/client/src/app/shared/shared-main/account/account.model.ts +++ b/client/src/app/shared/shared-main/account/account.model.ts @@ -1,4 +1,4 @@ -import { Account as ServerAccount, ActorImage } from '@shared/models' +import { Account as ServerAccount, ActorImage, BlockStatus } from '@shared/models' import { Actor } from './actor.model' export class Account extends Actor implements ServerAccount { @@ -49,4 +49,11 @@ export class Account extends Actor implements ServerAccount { resetAvatar () { this.avatar = null } + + updateBlockStatus (blockStatus: BlockStatus) { + this.mutedByInstance = blockStatus.accounts[this.nameWithHostForced].blockedByServer + this.mutedByUser = blockStatus.accounts[this.nameWithHostForced].blockedByUser + this.mutedServerByUser = blockStatus.hosts[this.host].blockedByUser + this.mutedServerByInstance = blockStatus.hosts[this.host].blockedByServer + } } diff --git a/client/src/app/shared/shared-moderation/account-block-badges.component.html b/client/src/app/shared/shared-moderation/account-block-badges.component.html new file mode 100644 index 000000000..feac707c2 --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-block-badges.component.html @@ -0,0 +1,4 @@ +Muted +Instance muted +Muted by your instance +Instance muted by your instance diff --git a/client/src/app/shared/shared-moderation/account-block-badges.component.scss b/client/src/app/shared/shared-moderation/account-block-badges.component.scss new file mode 100644 index 000000000..ccc3666aa --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-block-badges.component.scss @@ -0,0 +1,9 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.badge { + @include margin-right(10px); + + height: fit-content; + font-size: 12px; +} diff --git a/client/src/app/shared/shared-moderation/account-block-badges.component.ts b/client/src/app/shared/shared-moderation/account-block-badges.component.ts new file mode 100644 index 000000000..a72601118 --- /dev/null +++ b/client/src/app/shared/shared-moderation/account-block-badges.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core' +import { Account } from '../shared-main' + +@Component({ + selector: 'my-account-block-badges', + styleUrls: [ './account-block-badges.component.scss' ], + templateUrl: './account-block-badges.component.html' +}) +export class AccountBlockBadgesComponent { + @Input() account: Account +} diff --git a/client/src/app/shared/shared-moderation/blocklist.service.ts b/client/src/app/shared/shared-moderation/blocklist.service.ts index db2a8c584..f4836c6c4 100644 --- a/client/src/app/shared/shared-moderation/blocklist.service.ts +++ b/client/src/app/shared/shared-moderation/blocklist.service.ts @@ -3,7 +3,7 @@ import { catchError, map } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' -import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '@shared/models' +import { AccountBlock as AccountBlockServer, BlockStatus, ResultList, ServerBlock } from '@shared/models' import { environment } from '../../../environments/environment' import { Account } from '../shared-main' import { AccountBlock } from './account-block.model' @@ -12,6 +12,7 @@ export enum BlocklistComponentType { Account, Instance } @Injectable() export class BlocklistService { + static BASE_BLOCKLIST_URL = environment.apiUrl + '/api/v1/blocklist' static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist' static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist' @@ -21,6 +22,23 @@ export class BlocklistService { private restService: RestService ) { } + /** ********************* Blocklist status ***********************/ + + getStatus (options: { + accounts?: string[] + hosts?: string[] + }) { + const { accounts, hosts } = options + + let params = new HttpParams() + + if (accounts) params = this.restService.addArrayParams(params, 'accounts', accounts) + if (hosts) params = this.restService.addArrayParams(params, 'hosts', hosts) + + return this.authHttp.get(BlocklistService.BASE_BLOCKLIST_URL + '/status', { params }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + /** ********************* User -> Account blocklist ***********************/ getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) { diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts index 41c910ffe..da85b2299 100644 --- a/client/src/app/shared/shared-moderation/index.ts +++ b/client/src/app/shared/shared-moderation/index.ts @@ -1,6 +1,7 @@ export * from './report-modals' export * from './abuse.service' +export * from './account-block-badges.component' export * from './account-block.model' export * from './account-blocklist.component' export * from './batch-domains-modal.component' diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts index 95213e2bd..7cadda67c 100644 --- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts +++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts @@ -13,6 +13,7 @@ import { UserBanModalComponent } from './user-ban-modal.component' import { UserModerationDropdownComponent } from './user-moderation-dropdown.component' import { VideoBlockComponent } from './video-block.component' import { VideoBlockService } from './video-block.service' +import { AccountBlockBadgesComponent } from './account-block-badges.component' import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' @NgModule({ @@ -31,7 +32,8 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image VideoReportComponent, BatchDomainsModalComponent, CommentReportComponent, - AccountReportComponent + AccountReportComponent, + AccountBlockBadgesComponent ], exports: [ @@ -41,7 +43,8 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image VideoReportComponent, BatchDomainsModalComponent, CommentReportComponent, - AccountReportComponent + AccountReportComponent, + AccountBlockBadgesComponent ], providers: [ diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts index b18d861d6..e2cd2cdc1 100644 --- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts +++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts @@ -289,13 +289,13 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges { { label: $localize`Mute the instance`, description: $localize`Hide any content from that instance for you.`, - isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false, + isDisplayed: ({ account }) => !account.userId && account.mutedServerByUser === false, handler: ({ account }) => this.blockServerByUser(account.host) }, { label: $localize`Unmute the instance`, description: $localize`Show back content from that instance for you.`, - isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true, + isDisplayed: ({ account }) => !account.userId && account.mutedServerByUser === true, handler: ({ account }) => this.unblockServerByUser(account.host) }, { diff --git a/server/controllers/api/blocklist.ts b/server/controllers/api/blocklist.ts new file mode 100644 index 000000000..1e936ad10 --- /dev/null +++ b/server/controllers/api/blocklist.ts @@ -0,0 +1,108 @@ +import express from 'express' +import { handleToNameAndHost } from '@server/helpers/actors' +import { AccountBlocklistModel } from '@server/models/account/account-blocklist' +import { getServerActor } from '@server/models/application/application' +import { ServerBlocklistModel } from '@server/models/server/server-blocklist' +import { MActorAccountId, MUserAccountId } from '@server/types/models' +import { BlockStatus } from '@shared/models' +import { asyncMiddleware, blocklistStatusValidator, optionalAuthenticate } from '../../middlewares' +import { logger } from '@server/helpers/logger' + +const blocklistRouter = express.Router() + +blocklistRouter.get('/status', + optionalAuthenticate, + blocklistStatusValidator, + asyncMiddleware(getBlocklistStatus) +) + +// --------------------------------------------------------------------------- + +export { + blocklistRouter +} + +// --------------------------------------------------------------------------- + +async function getBlocklistStatus (req: express.Request, res: express.Response) { + const hosts = req.query.hosts as string[] + const accounts = req.query.accounts as string[] + const user = res.locals.oauth?.token.User + + const serverActor = await getServerActor() + + const byAccountIds = [ serverActor.Account.id ] + if (user) byAccountIds.push(user.Account.id) + + const status: BlockStatus = { + accounts: {}, + hosts: {} + } + + const baseOptions = { + byAccountIds, + user, + serverActor, + status + } + + await Promise.all([ + populateServerBlocklistStatus({ ...baseOptions, hosts }), + populateAccountBlocklistStatus({ ...baseOptions, accounts }) + ]) + + return res.json(status) +} + +async function populateServerBlocklistStatus (options: { + byAccountIds: number[] + user?: MUserAccountId + serverActor: MActorAccountId + hosts: string[] + status: BlockStatus +}) { + const { byAccountIds, user, serverActor, hosts, status } = options + + if (!hosts || hosts.length === 0) return + + const serverBlocklistStatus = await ServerBlocklistModel.getBlockStatus(byAccountIds, hosts) + + logger.debug('Got server blocklist status.', { serverBlocklistStatus, byAccountIds, hosts }) + + for (const host of hosts) { + const block = serverBlocklistStatus.find(b => b.host === host) + + status.hosts[host] = getStatus(block, serverActor, user) + } +} + +async function populateAccountBlocklistStatus (options: { + byAccountIds: number[] + user?: MUserAccountId + serverActor: MActorAccountId + accounts: string[] + status: BlockStatus +}) { + const { byAccountIds, user, serverActor, accounts, status } = options + + if (!accounts || accounts.length === 0) return + + const accountBlocklistStatus = await AccountBlocklistModel.getBlockStatus(byAccountIds, accounts) + + logger.debug('Got account blocklist status.', { accountBlocklistStatus, byAccountIds, accounts }) + + for (const account of accounts) { + const sanitizedHandle = handleToNameAndHost(account) + + const block = accountBlocklistStatus.find(b => b.name === sanitizedHandle.name && b.host === sanitizedHandle.host) + + status.accounts[sanitizedHandle.handle] = getStatus(block, serverActor, user) + } +} + +function getStatus (block: { accountId: number }, serverActor: MActorAccountId, user?: MUserAccountId) { + return { + blockedByServer: !!(block && block.accountId === serverActor.Account.id), + blockedByUser: !!(block && user && block.accountId === user.Account.id) + } +} diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index 9949b378a..5f49336b1 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts @@ -6,6 +6,7 @@ import { badRequest } from '../../helpers/express-utils' import { CONFIG } from '../../initializers/config' import { abuseRouter } from './abuse' import { accountsRouter } from './accounts' +import { blocklistRouter } from './blocklist' import { bulkRouter } from './bulk' import { configRouter } from './config' import { customPageRouter } from './custom-page' @@ -49,6 +50,7 @@ apiRouter.use('/search', searchRouter) apiRouter.use('/overviews', overviewsRouter) apiRouter.use('/plugins', pluginRouter) apiRouter.use('/custom-pages', customPageRouter) +apiRouter.use('/blocklist', blocklistRouter) apiRouter.use('/ping', pong) apiRouter.use('/*', badRequest) diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts index 6799ca8c5..fb1f68635 100644 --- a/server/controllers/api/users/my-subscriptions.ts +++ b/server/controllers/api/users/my-subscriptions.ts @@ -1,5 +1,6 @@ import 'multer' import express from 'express' +import { handlesToNameAndHost } from '@server/helpers/actors' import { pickCommonVideoQuery } from '@server/helpers/query' import { sendUndoFollow } from '@server/lib/activitypub/send' import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' @@ -7,7 +8,6 @@ import { VideoChannelModel } from '@server/models/video/video-channel' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' import { getFormattedObjects } from '../../../helpers/utils' -import { WEBSERVER } from '../../../initializers/constants' import { sequelizeTypescript } from '../../../initializers/database' import { JobQueue } from '../../../lib/job-queue' import { @@ -89,28 +89,23 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons const uris = req.query.uris as string[] const user = res.locals.oauth.token.User - const handles = uris.map(u => { - let [ name, host ] = u.split('@') - if (host === WEBSERVER.HOST) host = null + const sanitizedHandles = handlesToNameAndHost(uris) - return { name, host, uri: u } - }) - - const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, handles) + const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, sanitizedHandles) const existObject: { [id: string ]: boolean } = {} - for (const handle of handles) { + for (const sanitizedHandle of sanitizedHandles) { const obj = results.find(r => { const server = r.ActorFollowing.Server - return r.ActorFollowing.preferredUsername === handle.name && + return r.ActorFollowing.preferredUsername === sanitizedHandle.name && ( - (!server && !handle.host) || - (server.host === handle.host) + (!server && !sanitizedHandle.host) || + (server.host === sanitizedHandle.host) ) }) - existObject[handle.uri] = obj !== undefined + existObject[sanitizedHandle.handle] = obj !== undefined } return res.json(existObject) diff --git a/server/helpers/actors.ts b/server/helpers/actors.ts new file mode 100644 index 000000000..c31fe6f8e --- /dev/null +++ b/server/helpers/actors.ts @@ -0,0 +1,17 @@ +import { WEBSERVER } from '@server/initializers/constants' + +function handleToNameAndHost (handle: string) { + let [ name, host ] = handle.split('@') + if (host === WEBSERVER.HOST) host = null + + return { name, host, handle } +} + +function handlesToNameAndHost (handles: string[]) { + return handles.map(h => handleToNameAndHost(h)) +} + +export { + handleToNameAndHost, + handlesToNameAndHost +} diff --git a/server/lib/blocklist.ts b/server/lib/blocklist.ts index d6b684015..98273a6ea 100644 --- a/server/lib/blocklist.ts +++ b/server/lib/blocklist.ts @@ -40,12 +40,12 @@ async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAc if (userAccount) sourceAccounts.push(userAccount.id) - const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, targetAccount.id) + const accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, targetAccount.id) if (accountMutedHash[serverAccountId] || (userAccount && accountMutedHash[userAccount.id])) { return true } - const instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, targetAccount.Actor.serverId) + const instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, targetAccount.Actor.serverId) if (instanceMutedHash[serverAccountId] || (userAccount && instanceMutedHash[userAccount.id])) { return true } diff --git a/server/lib/notifier/shared/comment/comment-mention.ts b/server/lib/notifier/shared/comment/comment-mention.ts index 4f84d8dea..765cbaad9 100644 --- a/server/lib/notifier/shared/comment/comment-mention.ts +++ b/server/lib/notifier/shared/comment/comment-mention.ts @@ -47,8 +47,8 @@ export class CommentMention extends AbstractNotification u.Account.id).concat([ this.serverAccountId ]) - this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, this.payload.accountId) - this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, this.payload.Account.Actor.serverId) + this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, this.payload.accountId) + this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, this.payload.Account.Actor.serverId) } log () { diff --git a/server/middlewares/validators/blocklist.ts b/server/middlewares/validators/blocklist.ts index b7749e204..12980ced4 100644 --- a/server/middlewares/validators/blocklist.ts +++ b/server/middlewares/validators/blocklist.ts @@ -1,8 +1,10 @@ import express from 'express' -import { body, param } from 'express-validator' +import { body, param, query } from 'express-validator' +import { areValidActorHandles } from '@server/helpers/custom-validators/activitypub/actor' +import { toArray } from '@server/helpers/custom-validators/misc' import { getServerActor } from '@server/models/application/application' import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' -import { isHostValid } from '../../helpers/custom-validators/servers' +import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers' import { logger } from '../../helpers/logger' import { WEBSERVER } from '../../initializers/constants' import { AccountBlocklistModel } from '../../models/account/account-blocklist' @@ -123,6 +125,26 @@ const unblockServerByServerValidator = [ } ] +const blocklistStatusValidator = [ + query('hosts') + .optional() + .customSanitizer(toArray) + .custom(isEachUniqueHostValid).withMessage('Should have a valid hosts array'), + + query('accounts') + .optional() + .customSanitizer(toArray) + .custom(areValidActorHandles).withMessage('Should have a valid accounts array'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking blocklistStatusValidator parameters', { query: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -131,7 +153,8 @@ export { unblockAccountByAccountValidator, unblockServerByAccountValidator, unblockAccountByServerValidator, - unblockServerByServerValidator + unblockServerByServerValidator, + blocklistStatusValidator } // --------------------------------------------------------------------------- diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts index b2375b006..21983428a 100644 --- a/server/models/account/account-blocklist.ts +++ b/server/models/account/account-blocklist.ts @@ -1,11 +1,12 @@ -import { Op } from 'sequelize' +import { Op, QueryTypes } from 'sequelize' import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' +import { handlesToNameAndHost } from '@server/helpers/actors' import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' import { AttributesOnly } from '@shared/core-utils' import { AccountBlock } from '../../../shared/models' import { ActorModel } from '../actor/actor' import { ServerModel } from '../server/server' -import { getSort, searchAttribute } from '../utils' +import { createSafeIn, getSort, searchAttribute } from '../utils' import { AccountModel } from './account' enum ScopeNames { @@ -77,7 +78,7 @@ export class AccountBlocklistModel extends Model entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`)) } + static getBlockStatus (byAccountIds: number[], handles: string[]): Promise<{ name: string, host: string, accountId: number }[]> { + const sanitizedHandles = handlesToNameAndHost(handles) + + const localHandles = sanitizedHandles.filter(h => !h.host) + .map(h => h.name) + + const remoteHandles = sanitizedHandles.filter(h => !!h.host) + .map(h => ([ h.name, h.host ])) + + const handlesWhere: string[] = [] + + if (localHandles.length !== 0) { + handlesWhere.push(`("actor"."preferredUsername" IN (:localHandles) AND "server"."id" IS NULL)`) + } + + if (remoteHandles.length !== 0) { + handlesWhere.push(`(("actor"."preferredUsername", "server"."host") IN (:remoteHandles))`) + } + + const rawQuery = `SELECT "accountBlocklist"."accountId", "actor"."preferredUsername" AS "name", "server"."host" ` + + `FROM "accountBlocklist" ` + + `INNER JOIN "account" ON "account"."id" = "accountBlocklist"."targetAccountId" ` + + `INNER JOIN "actor" ON "actor"."id" = "account"."actorId" ` + + `LEFT JOIN "server" ON "server"."id" = "actor"."serverId" ` + + `WHERE "accountBlocklist"."accountId" IN (${createSafeIn(AccountBlocklistModel.sequelize, byAccountIds)}) ` + + `AND (${handlesWhere.join(' OR ')})` + + return AccountBlocklistModel.sequelize.query(rawQuery, { + type: QueryTypes.SELECT as QueryTypes.SELECT, + replacements: { byAccountIds, localHandles, remoteHandles } + }) + } + toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock { return { byAccount: this.ByAccount.toFormattedJSON(), diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts index b3579d589..092998db3 100644 --- a/server/models/server/server-blocklist.ts +++ b/server/models/server/server-blocklist.ts @@ -1,10 +1,10 @@ -import { Op } from 'sequelize' +import { Op, QueryTypes } from 'sequelize' import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' import { AttributesOnly } from '@shared/core-utils' import { ServerBlock } from '@shared/models' import { AccountModel } from '../account/account' -import { getSort, searchAttribute } from '../utils' +import { createSafeIn, getSort, searchAttribute } from '../utils' import { ServerModel } from './server' enum ScopeNames { @@ -76,7 +76,7 @@ export class ServerBlocklistModel extends Model entries.map(e => e.BlockedServer.host)) } + static getBlockStatus (byAccountIds: number[], hosts: string[]): Promise<{ host: string, accountId: number }[]> { + const rawQuery = `SELECT "server"."host", "serverBlocklist"."accountId" ` + + `FROM "serverBlocklist" ` + + `INNER JOIN "server" ON "server"."id" = "serverBlocklist"."targetServerId" ` + + `WHERE "server"."host" IN (:hosts) ` + + `AND "serverBlocklist"."accountId" IN (${createSafeIn(ServerBlocklistModel.sequelize, byAccountIds)})` + + return ServerBlocklistModel.sequelize.query(rawQuery, { + type: QueryTypes.SELECT as QueryTypes.SELECT, + replacements: { hosts } + }) + } + static listForApi (parameters: { start: number count: number diff --git a/server/tests/api/check-params/blocklist.ts b/server/tests/api/check-params/blocklist.ts index 7d5fae5cf..f72a892e2 100644 --- a/server/tests/api/check-params/blocklist.ts +++ b/server/tests/api/check-params/blocklist.ts @@ -481,6 +481,78 @@ describe('Test blocklist API validators', function () { }) }) + describe('When getting blocklist status', function () { + const path = '/api/v1/blocklist/status' + + it('Should fail with a bad token', async function () { + await makeGetRequest({ + url: server.url, + path, + token: 'false', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad accounts field', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + accounts: 1 + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path, + query: { + accounts: [ 1 ] + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad hosts field', async function () { + await makeGetRequest({ + url: server.url, + path, + query: { + hosts: 1 + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await makeGetRequest({ + url: server.url, + path, + query: { + hosts: [ 1 ] + }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeGetRequest({ + url: server.url, + path, + query: {}, + expectedStatus: HttpStatusCode.OK_200 + }) + + await makeGetRequest({ + url: server.url, + path, + query: { + hosts: [ 'example.com' ], + accounts: [ 'john@example.com' ] + }, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + }) + after(async function () { await cleanupTests(servers) }) diff --git a/server/tests/api/moderation/blocklist.ts b/server/tests/api/moderation/blocklist.ts index 089af8b15..b3fd8ecac 100644 --- a/server/tests/api/moderation/blocklist.ts +++ b/server/tests/api/moderation/blocklist.ts @@ -254,6 +254,45 @@ describe('Test blocklist', function () { } }) + it('Should get blocked status', async function () { + const remoteHandle = 'user2@' + servers[1].host + const localHandle = 'user1@' + servers[0].host + const unknownHandle = 'user5@' + servers[0].host + + { + const status = await command.getStatus({ accounts: [ remoteHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(1) + expect(status.accounts[remoteHandle].blockedByUser).to.be.false + expect(status.accounts[remoteHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + + { + const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ remoteHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(1) + expect(status.accounts[remoteHandle].blockedByUser).to.be.true + expect(status.accounts[remoteHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + + { + const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ localHandle, remoteHandle, unknownHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(3) + + for (const handle of [ localHandle, remoteHandle ]) { + expect(status.accounts[handle].blockedByUser).to.be.true + expect(status.accounts[handle].blockedByServer).to.be.false + } + + expect(status.accounts[unknownHandle].blockedByUser).to.be.false + expect(status.accounts[unknownHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + }) + it('Should not allow a remote blocked user to comment my videos', async function () { this.timeout(60000) @@ -434,6 +473,35 @@ describe('Test blocklist', function () { expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port) }) + it('Should get blocklist status', async function () { + const blockedServer = servers[1].host + const notBlockedServer = 'example.com' + + { + const status = await command.getStatus({ hosts: [ blockedServer, notBlockedServer ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(0) + + expect(Object.keys(status.hosts)).to.have.lengthOf(2) + expect(status.hosts[blockedServer].blockedByUser).to.be.false + expect(status.hosts[blockedServer].blockedByServer).to.be.false + + expect(status.hosts[notBlockedServer].blockedByUser).to.be.false + expect(status.hosts[notBlockedServer].blockedByServer).to.be.false + } + + { + const status = await command.getStatus({ token: servers[0].accessToken, hosts: [ blockedServer, notBlockedServer ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(0) + + expect(Object.keys(status.hosts)).to.have.lengthOf(2) + expect(status.hosts[blockedServer].blockedByUser).to.be.true + expect(status.hosts[blockedServer].blockedByServer).to.be.false + + expect(status.hosts[notBlockedServer].blockedByUser).to.be.false + expect(status.hosts[notBlockedServer].blockedByServer).to.be.false + } + }) + it('Should unblock the remote server', async function () { await command.removeFromMyBlocklist({ server: 'localhost:' + servers[1].port }) }) @@ -575,6 +643,27 @@ describe('Test blocklist', function () { } }) + it('Should get blocked status', async function () { + const remoteHandle = 'user2@' + servers[1].host + const localHandle = 'user1@' + servers[0].host + const unknownHandle = 'user5@' + servers[0].host + + for (const token of [ undefined, servers[0].accessToken ]) { + const status = await command.getStatus({ token, accounts: [ localHandle, remoteHandle, unknownHandle ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(3) + + for (const handle of [ localHandle, remoteHandle ]) { + expect(status.accounts[handle].blockedByUser).to.be.false + expect(status.accounts[handle].blockedByServer).to.be.true + } + + expect(status.accounts[unknownHandle].blockedByUser).to.be.false + expect(status.accounts[unknownHandle].blockedByServer).to.be.false + + expect(Object.keys(status.hosts)).to.have.lengthOf(0) + } + }) + it('Should unblock the remote account', async function () { await command.removeFromServerBlocklist({ account: 'user2@localhost:' + servers[1].port }) }) @@ -620,6 +709,7 @@ describe('Test blocklist', function () { }) describe('When managing server blocklist', function () { + it('Should list all videos', async function () { for (const token of [ userModeratorToken, servers[0].accessToken ]) { await checkAllVideos(servers[0], token) @@ -713,6 +803,23 @@ describe('Test blocklist', function () { expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port) }) + it('Should get blocklist status', async function () { + const blockedServer = servers[1].host + const notBlockedServer = 'example.com' + + for (const token of [ undefined, servers[0].accessToken ]) { + const status = await command.getStatus({ token, hosts: [ blockedServer, notBlockedServer ] }) + expect(Object.keys(status.accounts)).to.have.lengthOf(0) + + expect(Object.keys(status.hosts)).to.have.lengthOf(2) + expect(status.hosts[blockedServer].blockedByUser).to.be.false + expect(status.hosts[blockedServer].blockedByServer).to.be.true + + expect(status.hosts[notBlockedServer].blockedByUser).to.be.false + expect(status.hosts[notBlockedServer].blockedByServer).to.be.false + } + }) + it('Should unblock the remote server', async function () { await command.removeFromServerBlocklist({ server: 'localhost:' + servers[1].port }) }) diff --git a/shared/extra-utils/users/blocklist-command.ts b/shared/extra-utils/users/blocklist-command.ts index 14491a1ae..2e7ed074d 100644 --- a/shared/extra-utils/users/blocklist-command.ts +++ b/shared/extra-utils/users/blocklist-command.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { AccountBlock, HttpStatusCode, ResultList, ServerBlock } from '@shared/models' +import { AccountBlock, BlockStatus, HttpStatusCode, ResultList, ServerBlock } from '@shared/models' import { AbstractCommand, OverrideCommandOptions } from '../shared' type ListBlocklistOptions = OverrideCommandOptions & { @@ -37,6 +37,29 @@ export class BlocklistCommand extends AbstractCommand { // --------------------------------------------------------------------------- + getStatus (options: OverrideCommandOptions & { + accounts?: string[] + hosts?: string[] + }) { + const { accounts, hosts } = options + + const path = '/api/v1/blocklist/status' + + return this.getRequestBody({ + ...options, + + path, + query: { + accounts, + hosts + }, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + addToMyBlocklist (options: OverrideCommandOptions & { account?: string server?: string diff --git a/shared/models/moderation/block-status.model.ts b/shared/models/moderation/block-status.model.ts new file mode 100644 index 000000000..597312757 --- /dev/null +++ b/shared/models/moderation/block-status.model.ts @@ -0,0 +1,15 @@ +export interface BlockStatus { + accounts: { + [ handle: string ]: { + blockedByServer: boolean + blockedByUser?: boolean + } + } + + hosts: { + [ host: string ]: { + blockedByServer: boolean + blockedByUser?: boolean + } + } +} diff --git a/shared/models/moderation/index.ts b/shared/models/moderation/index.ts index 8b6042e97..f8e6d351c 100644 --- a/shared/models/moderation/index.ts +++ b/shared/models/moderation/index.ts @@ -1,3 +1,4 @@ export * from './abuse' +export * from './block-status.model' export * from './account-block.model' export * from './server-block.model'