diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4cb4d47912..267c49e312 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1203,6 +1203,9 @@ desktop/views/pages/user/user.profile.vue: mute: "ミュートする" muted: "ミュートしています" unmute: "ミュート解除" + block: "ブロックする" + unblock: "ブロック解除" + block-confirm: "このユーザーをブロックしますか?" push-to-a-list: "リストに追加" list-pushed: "{user}を{list}に追加しました。" diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue index fe864f0d7b..a075995e6e 100644 --- a/src/client/app/desktop/views/pages/user/user.profile.vue +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -13,6 +13,10 @@ %fa:eye% %i18n:@unmute% %fa:eye-slash% %i18n:@mute% + + %fa:user% %i18n:@unblock% + %fa:user-slash% %i18n:@block% + %fa:list% %i18n:@push-to-a-list% @@ -66,6 +70,27 @@ export default Vue.extend({ }); }, + block() { + if (!window.confirm('%i18n:@block-confirm%')) return; + (this as any).api('blocking/create', { + userId: this.user.id + }).then(() => { + this.user.isBlocking = true; + }, () => { + alert('error'); + }); + }, + + unblock() { + (this as any).api('blocking/delete', { + userId: this.user.id + }).then(() => { + this.user.isBlocking = false; + }, () => { + alert('error'); + }); + }, + list() { const w = (this as any).os.new(MkUserListsWindow); w.$once('choosen', async list => { diff --git a/src/models/blocking.ts b/src/models/blocking.ts new file mode 100644 index 0000000000..9a6e4ce42d --- /dev/null +++ b/src/models/blocking.ts @@ -0,0 +1,41 @@ +import * as mongo from 'mongodb'; +import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; + +const Blocking = db.get('blocking'); +Blocking.createIndex(['blockerId', 'blockeeId'], { unique: true }); +export default Blocking; + +export type IBlocking = { + _id: mongo.ObjectID; + createdAt: Date; + blockeeId: mongo.ObjectID; + blockerId: mongo.ObjectID; +}; + +/** + * Blockingを物理削除します + */ +export async function deleteBlocking(blocking: string | mongo.ObjectID | IBlocking) { + let f: IBlocking; + + // Populate + if (isObjectId(blocking)) { + f = await Blocking.findOne({ + _id: blocking + }); + } else if (typeof blocking === 'string') { + f = await Blocking.findOne({ + _id: new mongo.ObjectID(blocking) + }); + } else { + f = blocking as IBlocking; + } + + if (f == null) return; + + // このBlockingを削除 + await Blocking.remove({ + _id: f._id + }); +} diff --git a/src/models/user.ts b/src/models/user.ts index 25c4a9eb0f..e13802595c 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -6,6 +6,7 @@ import db from '../db/mongodb'; import isObjectId from '../misc/is-objectid'; import Note, { packMany as packNoteMany, deleteNote } from './note'; import Following, { deleteFollowing } from './following'; +import Blocking, { deleteBlocking } from './blocking'; import Mute, { deleteMute } from './mute'; import { getFriendIds } from '../server/api/common/get-friends'; import config from '../config'; @@ -275,6 +276,16 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { await FollowRequest.find({ followeeId: u._id }) ).map(x => deleteFollowRequest(x))); + // このユーザーのBlockingをすべて削除 + await Promise.all(( + await Blocking.find({ blockerId: u._id }) + ).map(x => deleteBlocking(x))); + + // このユーザーへのBlockingをすべて削除 + await Promise.all(( + await Blocking.find({ blockeeId: u._id }) + ).map(x => deleteBlocking(x))); + // このユーザーのSwSubscriptionをすべて削除 await Promise.all(( await SwSubscription.find({ userId: u._id }) @@ -427,7 +438,7 @@ export const pack = ( } if (meId && !meId.equals(_user.id)) { - const [following1, following2, followReq1, followReq2, mute] = await Promise.all([ + const [following1, following2, followReq1, followReq2, toBlocking, fromBlocked, mute] = await Promise.all([ Following.findOne({ followerId: meId, followeeId: _user.id @@ -444,6 +455,14 @@ export const pack = ( followerId: _user.id, followeeId: meId }), + Blocking.findOne({ + blockerId: meId, + blockeeId: _user.id + }), + Blocking.findOne({ + blockerId: _user.id, + blockeeId: meId + }), Mute.findOne({ muterId: meId, muteeId: _user.id @@ -460,6 +479,12 @@ export const pack = ( // Whether the user is followed _user.isFollowed = following2 !== null; + // Whether the user is blocking + _user.isBlocking = toBlocking !== null; + + // Whether the user is blocked + _user.isBlocked = fromBlocked !== null; + // Whether the user is muted _user.isMuted = mute !== null; } diff --git a/src/remote/activitypub/kernel/block/index.ts b/src/remote/activitypub/kernel/block/index.ts new file mode 100644 index 0000000000..dec591accf --- /dev/null +++ b/src/remote/activitypub/kernel/block/index.ts @@ -0,0 +1,34 @@ +import * as mongo from 'mongodb'; +import User, { IRemoteUser } from '../../../../models/user'; +import config from '../../../../config'; +import * as debug from 'debug'; +import { IBlock } from '../../type'; +import block from '../../../../services/blocking/create'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IBlock): Promise => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + const uri = activity.id || activity; + + log(`Block: ${uri}`); + + if (!id.startsWith(config.url + '/')) { + return null; + } + + const blockee = await User.findOne({ + _id: new mongo.ObjectID(id.split('/').pop()) + }); + + if (blockee === null) { + throw new Error('blockee not found'); + } + + if (blockee.host != null) { + throw new Error('ブロックしようとしているユーザーはローカルユーザーではありません'); + } + + block(actor, blockee); +}; diff --git a/src/remote/activitypub/kernel/index.ts b/src/remote/activitypub/kernel/index.ts index 52b0efc730..61bb89f5e9 100644 --- a/src/remote/activitypub/kernel/index.ts +++ b/src/remote/activitypub/kernel/index.ts @@ -10,6 +10,7 @@ import accept from './accept'; import reject from './reject'; import add from './add'; import remove from './remove'; +import block from './block'; const self = async (actor: IRemoteUser, activity: Object): Promise => { switch (activity.type) { @@ -53,6 +54,10 @@ const self = async (actor: IRemoteUser, activity: Object): Promise => { await undo(actor, activity); break; + case 'Block': + await block(actor, activity); + break; + case 'Collection': case 'OrderedCollection': // TODO diff --git a/src/remote/activitypub/kernel/undo/block.ts b/src/remote/activitypub/kernel/undo/block.ts new file mode 100644 index 0000000000..b735f114d0 --- /dev/null +++ b/src/remote/activitypub/kernel/undo/block.ts @@ -0,0 +1,34 @@ +import * as mongo from 'mongodb'; +import User, { IRemoteUser } from '../../../../models/user'; +import config from '../../../../config'; +import * as debug from 'debug'; +import { IBlock } from '../../type'; +import unblock from '../../../../services/blocking/delete'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IBlock): Promise => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + const uri = activity.id || activity; + + log(`UnBlock: ${uri}`); + + if (!id.startsWith(config.url + '/')) { + return null; + } + + const blockee = await User.findOne({ + _id: new mongo.ObjectID(id.split('/').pop()) + }); + + if (blockee === null) { + throw new Error('blockee not found'); + } + + if (blockee.host != null) { + throw new Error('ブロック解除しようとしているユーザーはローカルユーザーではありません'); + } + + unblock(actor, blockee); +}; diff --git a/src/remote/activitypub/kernel/undo/index.ts b/src/remote/activitypub/kernel/undo/index.ts index 5d9535403b..ba56dd6328 100644 --- a/src/remote/activitypub/kernel/undo/index.ts +++ b/src/remote/activitypub/kernel/undo/index.ts @@ -1,8 +1,9 @@ import * as debug from 'debug'; import { IRemoteUser } from '../../../../models/user'; -import { IUndo, IFollow } from '../../type'; +import { IUndo, IFollow, IBlock } from '../../type'; import unfollow from './follow'; +import unblock from './block'; import Resolver from '../../resolver'; const log = debug('misskey:activitypub'); @@ -31,6 +32,9 @@ export default async (actor: IRemoteUser, activity: IUndo): Promise => { case 'Follow': unfollow(actor, object as IFollow); break; + case 'Block': + unblock(actor, object as IBlock); + break; } return null; diff --git a/src/remote/activitypub/renderer/block.ts b/src/remote/activitypub/renderer/block.ts new file mode 100644 index 0000000000..316fc13c05 --- /dev/null +++ b/src/remote/activitypub/renderer/block.ts @@ -0,0 +1,8 @@ +import config from '../../../config'; +import { ILocalUser, IRemoteUser } from "../../../models/user"; + +export default (blocker?: ILocalUser, blockee?: IRemoteUser) => ({ + type: 'Block', + actor: `${config.url}/users/${blocker._id}`, + object: blockee.uri +}); diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 5c06ee4ffe..2344035013 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -108,6 +108,10 @@ export interface IAnnounce extends IActivity { type: 'Announce'; } +export interface IBlock extends IActivity { + type: 'Block'; +} + export type Object = ICollection | IOrderedCollection | @@ -120,4 +124,5 @@ export type Object = IAdd | IRemove | ILike | - IAnnounce; + IAnnounce | + IBlock; diff --git a/src/server/api/endpoints/blocking/create.ts b/src/server/api/endpoints/blocking/create.ts new file mode 100644 index 0000000000..c235731190 --- /dev/null +++ b/src/server/api/endpoints/blocking/create.ts @@ -0,0 +1,75 @@ +import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; +const ms = require('ms'); +import User, { pack, ILocalUser } from '../../../../models/user'; +import Blocking from '../../../../models/blocking'; +import create from '../../../../services/blocking/create'; +import getParams from '../../get-params'; + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': '指定したユーザーをブロックします。', + 'en-US': 'Block a user.' + }, + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true, + + kind: 'following-write', + + params: { + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const blocker = user; + + // 自分自身 + if (user._id.equals(ps.userId)) { + return rej('blockee is yourself'); + } + + // Get blockee + const blockee = await User.findOne({ + _id: ps.userId + }, { + fields: { + data: false, + profile: false + } + }); + + if (blockee === null) { + return rej('user not found'); + } + + // Check if already blocking + const exist = await Blocking.findOne({ + blockerId: blocker._id, + blockeeId: blockee._id + }); + + if (exist !== null) { + return rej('already blocking'); + } + + // Create blocking + await create(blocker, blockee); + + // Send response + res(await pack(blockee._id, user)); +}); diff --git a/src/server/api/endpoints/blocking/delete.ts b/src/server/api/endpoints/blocking/delete.ts new file mode 100644 index 0000000000..dd0cf6b515 --- /dev/null +++ b/src/server/api/endpoints/blocking/delete.ts @@ -0,0 +1,75 @@ +import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; +const ms = require('ms'); +import User, { pack, ILocalUser } from '../../../../models/user'; +import Blocking from '../../../../models/blocking'; +import deleteBlocking from '../../../../services/blocking/delete'; +import getParams from '../../get-params'; + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': '指定したユーザーのブロックを解除します。', + 'en-US': 'Unblock a user.' + }, + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true, + + kind: 'following-write', + + params: { + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const blocker = user; + + // Check if the blockee is yourself + if (user._id.equals(ps.userId)) { + return rej('blockee is yourself'); + } + + // Get blockee + const blockee = await User.findOne({ + _id: ps.userId + }, { + fields: { + data: false, + 'profile': false + } + }); + + if (blockee === null) { + return rej('user not found'); + } + + // Check not blocking + const exist = await Blocking.findOne({ + blockerId: blocker._id, + blockeeId: blockee._id + }); + + if (exist === null) { + return rej('already not blocking'); + } + + // Delete blocking + await deleteBlocking(blocker, blockee); + + // Send response + res(await pack(blockee._id, user)); +}); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index 372bad0222..028a2aa826 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -68,7 +68,11 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } // Create following - await create(follower, followee); + try { + await create(follower, followee); + } catch (e) { + return rej(e && e.message ? e.message : e); + } // Send response res(await pack(followee._id, user)); diff --git a/src/services/blocking/create.ts b/src/services/blocking/create.ts new file mode 100644 index 0000000000..11b2954af6 --- /dev/null +++ b/src/services/blocking/create.ts @@ -0,0 +1,121 @@ +import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; +import Following from '../../models/following'; +import FollowRequest from '../../models/follow-request'; +import { publishMainStream } from '../../stream'; +import pack from '../../remote/activitypub/renderer'; +import renderFollow from '../../remote/activitypub/renderer/follow'; +import renderUndo from '../../remote/activitypub/renderer/undo'; +import renderBlock from '../../remote/activitypub/renderer/block'; +import { deliver } from '../../queue'; +import renderReject from '../../remote/activitypub/renderer/reject'; +import perUserFollowingChart from '../../chart/per-user-following'; +import Blocking from '../../models/blocking'; + +export default async function(blocker: IUser, blockee: IUser) { + + await Promise.all([ + cancelRequest(blocker, blockee), + cancelRequest(blockee, blocker), + unFollow(blocker, blockee), + unFollow(blockee, blocker) + ]); + + await Blocking.insert({ + createdAt: new Date(), + blockerId: blocker._id, + blockeeId: blockee._id, + }); + + if (isLocalUser(blocker) && isRemoteUser(blockee)) { + const content = pack(renderBlock(blocker, blockee)); + deliver(blocker, content, blockee.inbox); + } +} + +async function cancelRequest(follower: IUser, followee: IUser) { + const request = await FollowRequest.findOne({ + followeeId: followee._id, + followerId: follower._id + }); + + if (request == null) { + return; + } + + await FollowRequest.remove({ + followeeId: followee._id, + followerId: follower._id + }); + + await User.update({ _id: followee._id }, { + $inc: { + pendingReceivedFollowRequestsCount: -1 + } + }); + + if (isLocalUser(followee)) { + packUser(followee, followee, { + detail: true + }).then(packed => publishMainStream(followee._id, 'meUpdated', packed)); + } + + if (isLocalUser(follower)) { + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed)); + } + + // リモートにフォローリクエストをしていたらUndoFollow送信 + if (isLocalUser(follower) && isRemoteUser(followee)) { + const content = pack(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } + + // リモートからフォローリクエストを受けていたらReject送信 + if (isRemoteUser(follower) && isLocalUser(followee)) { + const content = pack(renderReject(renderFollow(follower, followee, request.requestId), followee)); + deliver(followee, content, follower.inbox); + } +} + +async function unFollow(follower: IUser, followee: IUser) { + const following = await Following.findOne({ + followerId: follower._id, + followeeId: followee._id + }); + + if (following == null) { + return; + } + + Following.remove({ + _id: following._id + }); + + //#region Decrement following count + User.update({ _id: follower._id }, { + $inc: { + followingCount: -1 + } + }); + //#endregion + + //#region Decrement followers count + User.update({ _id: followee._id }, { + $inc: { + followersCount: -1 + } + }); + //#endregion + + perUserFollowingChart.update(follower, followee, false); + + // Publish unfollow event + if (isLocalUser(follower)) { + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed)); + } + + // リモートにフォローをしていたらUndoFollow送信 + if (isLocalUser(follower) && isRemoteUser(followee)) { + const content = pack(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } +} diff --git a/src/services/blocking/delete.ts b/src/services/blocking/delete.ts new file mode 100644 index 0000000000..bc331d491a --- /dev/null +++ b/src/services/blocking/delete.ts @@ -0,0 +1,28 @@ +import { isLocalUser, isRemoteUser, IUser } from '../../models/user'; +import Blocking from '../../models/blocking'; +import pack from '../../remote/activitypub/renderer'; +import renderBlock from '../../remote/activitypub/renderer/block'; +import renderUndo from '../../remote/activitypub/renderer/undo'; +import { deliver } from '../../queue'; + +export default async function(blocker: IUser, blockee: IUser) { + const blocking = await Blocking.findOne({ + blockerId: blocker._id, + blockeeId: blockee._id + }); + + if (blocking == null) { + console.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); + return; + } + + Blocking.remove({ + _id: blocking._id + }); + + // deliver if remote bloking + if (isLocalUser(blocker) && isRemoteUser(blockee)) { + const content = pack(renderUndo(renderBlock(blocker, blockee), blocker)); + deliver(blocker, content, blockee.inbox); + } +} diff --git a/src/services/following/create.ts b/src/services/following/create.ts index 87d13c444b..38367399e3 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -1,15 +1,45 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; import Following from '../../models/following'; +import Blocking from '../../models/blocking'; import { publishMainStream } from '../../stream'; import notify from '../../notify'; import pack from '../../remote/activitypub/renderer'; import renderFollow from '../../remote/activitypub/renderer/follow'; import renderAccept from '../../remote/activitypub/renderer/accept'; +import renderReject from '../../remote/activitypub/renderer/reject'; import { deliver } from '../../queue'; import createFollowRequest from './requests/create'; import perUserFollowingChart from '../../chart/per-user-following'; export default async function(follower: IUser, followee: IUser, requestId?: string) { + // check blocking + const [ blocking, blocked ] = await Promise.all([ + Blocking.findOne({ + blockerId: follower._id, + blockeeId: followee._id, + }), + Blocking.findOne({ + blockerId: followee._id, + blockeeId: follower._id, + }) + ]); + + if (isRemoteUser(follower) && isLocalUser(followee) && blocked) { + // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 + const content = pack(renderReject(renderFollow(follower, followee, requestId), followee)); + deliver(followee , content, follower.inbox); + return; + } else if (isRemoteUser(follower) && isLocalUser(followee) && blocking) { + // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 + await Blocking.remove({ + _id: blocking._id + }); + } else { + // それ以外は単純に例外 + if (blocking != null) throw new Error('blocking'); + if (blocked != null) throw new Error('blocked'); + } + // フォロー対象が鍵アカウントである or // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである diff --git a/src/services/following/requests/create.ts b/src/services/following/requests/create.ts index d28c93929a..a87e472ad8 100644 --- a/src/services/following/requests/create.ts +++ b/src/services/following/requests/create.ts @@ -5,8 +5,24 @@ import pack from '../../../remote/activitypub/renderer'; import renderFollow from '../../../remote/activitypub/renderer/follow'; import { deliver } from '../../../queue'; import FollowRequest from '../../../models/follow-request'; +import Blocking from '../../../models/blocking'; export default async function(follower: IUser, followee: IUser, requestId?: string) { + // check blocking + const [ blocking, blocked ] = await Promise.all([ + Blocking.findOne({ + blockerId: follower._id, + blockeeId: followee._id, + }), + Blocking.findOne({ + blockerId: followee._id, + blockeeId: follower._id, + }) + ]); + + if (blocking != null) throw new Error('blocking'); + if (blocked != null) throw new Error('blocked'); + await FollowRequest.insert({ createdAt: new Date(), followerId: follower._id,