summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints/users
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api/endpoints/users
parentupdate deps (diff)
downloadsharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api/endpoints/users')
-rw-r--r--packages/backend/src/server/api/endpoints/users/clips.ts40
-rw-r--r--packages/backend/src/server/api/endpoints/users/followers.ts104
-rw-r--r--packages/backend/src/server/api/endpoints/users/following.ts104
-rw-r--r--packages/backend/src/server/api/endpoints/users/gallery/posts.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts105
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/create.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/delete.ts40
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts44
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/invite.ts102
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/joined.ts36
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/leave.ts50
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/owned.ts28
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/pull.ts69
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/show.ts55
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/transfer.ts83
-rw-r--r--packages/backend/src/server/api/endpoints/users/groups/update.ts55
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/create.ts36
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/delete.ts40
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/list.ts28
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/pull.ts62
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/push.ts92
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/show.ts47
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/update.ts55
-rw-r--r--packages/backend/src/server/api/endpoints/users/notes.ts144
-rw-r--r--packages/backend/src/server/api/endpoints/users/pages.ts40
-rw-r--r--packages/backend/src/server/api/endpoints/users/reactions.ts79
-rw-r--r--packages/backend/src/server/api/endpoints/users/recommendation.ts63
-rw-r--r--packages/backend/src/server/api/endpoints/users/relation.ts111
-rw-r--r--packages/backend/src/server/api/endpoints/users/report-abuse.ts90
-rw-r--r--packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts116
-rw-r--r--packages/backend/src/server/api/endpoints/users/search.ts127
-rw-r--r--packages/backend/src/server/api/endpoints/users/show.ts105
-rw-r--r--packages/backend/src/server/api/endpoints/users/stats.ts144
34 files changed, 2432 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts
new file mode 100644
index 0000000000..8feca9422a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/clips.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { Clips } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+
+export const meta = {
+ tags: ['users', 'clips'],
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = makePaginationQuery(Clips.createQueryBuilder('clip'), ps.sinceId, ps.untilId)
+ .andWhere(`clip.userId = :userId`, { userId: ps.userId })
+ .andWhere('clip.isPublic = true');
+
+ const clips = await query
+ .take(ps.limit!)
+ .getMany();
+
+ return await Clips.packMany(clips);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts
new file mode 100644
index 0000000000..6d042a2861
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/followers.ts
@@ -0,0 +1,104 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Users, Followings, UserProfiles } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { toPunyNullable } from '@/misc/convert-host';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ userId: {
+ validator: $.optional.type(ID),
+ },
+
+ username: {
+ validator: $.optional.str
+ },
+
+ host: {
+ validator: $.optional.nullable.str
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Following',
+ }
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '27fa5435-88ab-43de-9360-387de88727cd'
+ },
+
+ forbidden: {
+ message: 'Forbidden.',
+ code: 'FORBIDDEN',
+ id: '3c6a84db-d619-26af-ca14-06232a21df8a'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const user = await Users.findOne(ps.userId != null
+ ? { id: ps.userId }
+ : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) });
+
+ if (user == null) {
+ throw new ApiError(meta.errors.noSuchUser);
+ }
+
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ if (profile.ffVisibility === 'private') {
+ if (me == null || (me.id !== user.id)) {
+ throw new ApiError(meta.errors.forbidden);
+ }
+ } else if (profile.ffVisibility === 'followers') {
+ if (me == null) {
+ throw new ApiError(meta.errors.forbidden);
+ } else if (me.id !== user.id) {
+ const following = await Followings.findOne({
+ followeeId: user.id,
+ followerId: me.id,
+ });
+ if (following == null) {
+ throw new ApiError(meta.errors.forbidden);
+ }
+ }
+ }
+
+ const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId)
+ .andWhere(`following.followeeId = :userId`, { userId: user.id })
+ .innerJoinAndSelect('following.follower', 'follower');
+
+ const followings = await query
+ .take(ps.limit!)
+ .getMany();
+
+ return await Followings.packMany(followings, me, { populateFollower: true });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts
new file mode 100644
index 0000000000..1033117ef8
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/following.ts
@@ -0,0 +1,104 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { Users, Followings, UserProfiles } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { toPunyNullable } from '@/misc/convert-host';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ userId: {
+ validator: $.optional.type(ID),
+ },
+
+ username: {
+ validator: $.optional.str
+ },
+
+ host: {
+ validator: $.optional.nullable.str
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Following',
+ }
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '63e4aba4-4156-4e53-be25-c9559e42d71b'
+ },
+
+ forbidden: {
+ message: 'Forbidden.',
+ code: 'FORBIDDEN',
+ id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const user = await Users.findOne(ps.userId != null
+ ? { id: ps.userId }
+ : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) });
+
+ if (user == null) {
+ throw new ApiError(meta.errors.noSuchUser);
+ }
+
+ const profile = await UserProfiles.findOneOrFail(user.id);
+
+ if (profile.ffVisibility === 'private') {
+ if (me == null || (me.id !== user.id)) {
+ throw new ApiError(meta.errors.forbidden);
+ }
+ } else if (profile.ffVisibility === 'followers') {
+ if (me == null) {
+ throw new ApiError(meta.errors.forbidden);
+ } else if (me.id !== user.id) {
+ const following = await Followings.findOne({
+ followeeId: user.id,
+ followerId: me.id,
+ });
+ if (following == null) {
+ throw new ApiError(meta.errors.forbidden);
+ }
+ }
+ }
+
+ const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId)
+ .andWhere(`following.followerId = :userId`, { userId: user.id })
+ .innerJoinAndSelect('following.followee', 'followee');
+
+ const followings = await query
+ .take(ps.limit!)
+ .getMany();
+
+ return await Followings.packMany(followings, me, { populateFollowee: true });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts
new file mode 100644
index 0000000000..845de1089c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts
@@ -0,0 +1,39 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { GalleryPosts } from '@/models/index';
+import { makePaginationQuery } from '../../../common/make-pagination-query';
+
+export const meta = {
+ tags: ['users', 'gallery'],
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId)
+ .andWhere(`post.userId = :userId`, { userId: ps.userId });
+
+ const posts = await query
+ .take(ps.limit!)
+ .getMany();
+
+ return await GalleryPosts.packMany(posts, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts
new file mode 100644
index 0000000000..32ebfd683a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts
@@ -0,0 +1,105 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { maximum } from '@/prelude/array';
+import { ApiError } from '../../error';
+import { getUser } from '../../common/getters';
+import { Not, In, IsNull } from 'typeorm';
+import { Notes, Users } from '@/models/index';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'User',
+ }
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'e6965129-7b2a-40a4-bae2-cd84cd434822'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Lookup user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ // Fetch recent notes
+ const recentNotes = await Notes.find({
+ where: {
+ userId: user.id,
+ replyId: Not(IsNull())
+ },
+ order: {
+ id: -1
+ },
+ take: 1000,
+ select: ['replyId']
+ });
+
+ // 投稿が少なかったら中断
+ if (recentNotes.length === 0) {
+ return [];
+ }
+
+ // TODO ミュートを考慮
+ const replyTargetNotes = await Notes.find({
+ where: {
+ id: In(recentNotes.map(p => p.replyId)),
+ },
+ select: ['userId']
+ });
+
+ const repliedUsers: any = {};
+
+ // Extract replies from recent notes
+ for (const userId of replyTargetNotes.map(x => x.userId.toString())) {
+ if (repliedUsers[userId]) {
+ repliedUsers[userId]++;
+ } else {
+ repliedUsers[userId] = 1;
+ }
+ }
+
+ // Calc peak
+ const peak = maximum(Object.values(repliedUsers));
+
+ // Sort replies by frequency
+ const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]);
+
+ // Extract top replied users
+ const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit!);
+
+ // Make replies object (includes weights)
+ const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({
+ user: await Users.pack(user, me, { detail: true }),
+ weight: repliedUsers[user] / peak
+ })));
+
+ return repliesObj;
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts
new file mode 100644
index 0000000000..dc1ee3879e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts
@@ -0,0 +1,45 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { UserGroups, UserGroupJoinings } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+import { UserGroup } from '@/models/entities/user-group';
+import { UserGroupJoining } from '@/models/entities/user-group-joining';
+
+export const meta = {
+ tags: ['groups'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ name: {
+ validator: $.str.range(1, 100)
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserGroup',
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const userGroup = await UserGroups.insert({
+ id: genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ name: ps.name,
+ } as UserGroup).then(x => UserGroups.findOneOrFail(x.identifiers[0]));
+
+ // Push the owner
+ await UserGroupJoinings.insert({
+ id: genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ userGroupId: userGroup.id
+ } as UserGroupJoining);
+
+ return await UserGroups.pack(userGroup);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts
new file mode 100644
index 0000000000..7da1b4a273
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups } from '@/models/index';
+
+export const meta = {
+ tags: ['groups'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: '63dbd64c-cd77-413f-8e08-61781e210b38'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ userId: user.id
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ await UserGroups.delete(userGroup.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts
new file mode 100644
index 0000000000..09e6ae2647
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts
@@ -0,0 +1,54 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../../define';
+import { ApiError } from '../../../../error';
+import { UserGroupJoinings, UserGroupInvitations } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+import { UserGroupJoining } from '@/models/entities/user-group-joining';
+
+export const meta = {
+ tags: ['groups', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ invitationId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchInvitation: {
+ message: 'No such invitation.',
+ code: 'NO_SUCH_INVITATION',
+ id: '98c11eca-c890-4f42-9806-c8c8303ebb5e'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ // Fetch the invitation
+ const invitation = await UserGroupInvitations.findOne({
+ id: ps.invitationId,
+ });
+
+ if (invitation == null) {
+ throw new ApiError(meta.errors.noSuchInvitation);
+ }
+
+ if (invitation.userId !== user.id) {
+ throw new ApiError(meta.errors.noSuchInvitation);
+ }
+
+ // Push the user
+ await UserGroupJoinings.insert({
+ id: genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ userGroupId: invitation.userGroupId
+ } as UserGroupJoining);
+
+ UserGroupInvitations.delete(invitation.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts
new file mode 100644
index 0000000000..741fcefb35
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts
@@ -0,0 +1,44 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../../define';
+import { ApiError } from '../../../../error';
+import { UserGroupInvitations } from '@/models/index';
+
+export const meta = {
+ tags: ['groups', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ invitationId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchInvitation: {
+ message: 'No such invitation.',
+ code: 'NO_SUCH_INVITATION',
+ id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ // Fetch the invitation
+ const invitation = await UserGroupInvitations.findOne({
+ id: ps.invitationId,
+ });
+
+ if (invitation == null) {
+ throw new ApiError(meta.errors.noSuchInvitation);
+ }
+
+ if (invitation.userId !== user.id) {
+ throw new ApiError(meta.errors.noSuchInvitation);
+ }
+
+ await UserGroupInvitations.delete(invitation.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts
new file mode 100644
index 0000000000..f1ee8bf8b7
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts
@@ -0,0 +1,102 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserGroups, UserGroupJoinings, UserGroupInvitations } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+import { UserGroupInvitation } from '@/models/entities/user-group-invitation';
+import { createNotification } from '@/services/create-notification';
+
+export const meta = {
+ tags: ['groups', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ },
+
+ userId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: '583f8bc0-8eee-4b78-9299-1e14fc91e409'
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'da52de61-002c-475b-90e1-ba64f9cf13a8'
+ },
+
+ alreadyAdded: {
+ message: 'That user has already been added to that group.',
+ code: 'ALREADY_ADDED',
+ id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c'
+ },
+
+ alreadyInvited: {
+ message: 'That user has already been invited to that group.',
+ code: 'ALREADY_INVITED',
+ id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the group
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ userId: me.id,
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ // Fetch the user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ const joining = await UserGroupJoinings.findOne({
+ userGroupId: userGroup.id,
+ userId: user.id
+ });
+
+ if (joining) {
+ throw new ApiError(meta.errors.alreadyAdded);
+ }
+
+ const existInvitation = await UserGroupInvitations.findOne({
+ userGroupId: userGroup.id,
+ userId: user.id
+ });
+
+ if (existInvitation) {
+ throw new ApiError(meta.errors.alreadyInvited);
+ }
+
+ const invitation = await UserGroupInvitations.insert({
+ id: genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ userGroupId: userGroup.id
+ } as UserGroupInvitation).then(x => UserGroupInvitations.findOneOrFail(x.identifiers[0]));
+
+ // 通知を作成
+ createNotification(user.id, 'groupInvited', {
+ notifierId: me.id,
+ userGroupInvitationId: invitation.id
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts
new file mode 100644
index 0000000000..d5e8fe4032
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts
@@ -0,0 +1,36 @@
+import define from '../../../define';
+import { UserGroups, UserGroupJoinings } from '@/models/index';
+import { Not, In } from 'typeorm';
+
+export const meta = {
+ tags: ['groups', 'account'],
+
+ requireCredential: true as const,
+
+ kind: 'read:user-groups',
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserGroup',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const ownedGroups = await UserGroups.find({
+ userId: me.id,
+ });
+
+ const joinings = await UserGroupJoinings.find({
+ userId: me.id,
+ ...(ownedGroups.length > 0 ? {
+ userGroupId: Not(In(ownedGroups.map(x => x.id)))
+ } : {})
+ });
+
+ return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId)));
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts
new file mode 100644
index 0000000000..0e52f2abdf
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts
@@ -0,0 +1,50 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups, UserGroupJoinings } from '@/models/index';
+
+export const meta = {
+ tags: ['groups', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: '62780270-1f67-5dc0-daca-3eb510612e31'
+ },
+
+ youAreOwner: {
+ message: 'Your are the owner.',
+ code: 'YOU_ARE_OWNER',
+ id: 'b6d6e0c2-ef8a-9bb8-653d-79f4a3107c69'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the group
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ if (me.id === userGroup.userId) {
+ throw new ApiError(meta.errors.youAreOwner);
+ }
+
+ await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: me.id });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts
new file mode 100644
index 0000000000..17de370dbc
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts
@@ -0,0 +1,28 @@
+import define from '../../../define';
+import { UserGroups } from '@/models/index';
+
+export const meta = {
+ tags: ['groups', 'account'],
+
+ requireCredential: true as const,
+
+ kind: 'read:user-groups',
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserGroup',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const userGroups = await UserGroups.find({
+ userId: me.id,
+ });
+
+ return await Promise.all(userGroups.map(x => UserGroups.pack(x)));
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts
new file mode 100644
index 0000000000..ce4d2e2881
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts
@@ -0,0 +1,69 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserGroups, UserGroupJoinings } from '@/models/index';
+
+export const meta = {
+ tags: ['groups', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ },
+
+ userId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: '4662487c-05b1-4b78-86e5-fd46998aba74'
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '0b5cc374-3681-41da-861e-8bc1146f7a55'
+ },
+
+ isOwner: {
+ message: 'The user is the owner.',
+ code: 'IS_OWNER',
+ id: '1546eed5-4414-4dea-81c1-b0aec4f6d2af'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the group
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ userId: me.id,
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ // Fetch the user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ if (user.id === userGroup.userId) {
+ throw new ApiError(meta.errors.isOwner);
+ }
+
+ // Pull the user
+ await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: user.id });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts
new file mode 100644
index 0000000000..3c030bf3a5
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts
@@ -0,0 +1,55 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups, UserGroupJoinings } from '@/models/index';
+
+export const meta = {
+ tags: ['groups', 'account'],
+
+ requireCredential: true as const,
+
+ kind: 'read:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ },
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserGroup',
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the group
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ const joining = await UserGroupJoinings.findOne({
+ userId: me.id,
+ userGroupId: userGroup.id
+ });
+
+ if (joining == null && userGroup.userId !== me.id) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ return await UserGroups.pack(userGroup);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts
new file mode 100644
index 0000000000..17c42e1127
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts
@@ -0,0 +1,83 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserGroups, UserGroupJoinings } from '@/models/index';
+
+export const meta = {
+ tags: ['groups', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ },
+
+ userId: {
+ validator: $.type(ID),
+ },
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserGroup',
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db'
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9'
+ },
+
+ noSuchGroupMember: {
+ message: 'No such group member.',
+ code: 'NO_SUCH_GROUP_MEMBER',
+ id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the group
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ userId: me.id,
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ // Fetch the user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ const joining = await UserGroupJoinings.findOne({
+ userGroupId: userGroup.id,
+ userId: user.id
+ });
+
+ if (joining == null) {
+ throw new ApiError(meta.errors.noSuchGroupMember);
+ }
+
+ await UserGroups.update(userGroup.id, {
+ userId: ps.userId
+ });
+
+ return await UserGroups.pack(userGroup.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts
new file mode 100644
index 0000000000..127bbc47a1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts
@@ -0,0 +1,55 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserGroups } from '@/models/index';
+
+export const meta = {
+ tags: ['groups'],
+
+ requireCredential: true as const,
+
+ kind: 'write:user-groups',
+
+ params: {
+ groupId: {
+ validator: $.type(ID),
+ },
+
+ name: {
+ validator: $.str.range(1, 100),
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserGroup',
+ },
+
+ errors: {
+ noSuchGroup: {
+ message: 'No such group.',
+ code: 'NO_SUCH_GROUP',
+ id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the group
+ const userGroup = await UserGroups.findOne({
+ id: ps.groupId,
+ userId: me.id
+ });
+
+ if (userGroup == null) {
+ throw new ApiError(meta.errors.noSuchGroup);
+ }
+
+ await UserGroups.update(userGroup.id, {
+ name: ps.name
+ });
+
+ return await UserGroups.pack(userGroup.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts
new file mode 100644
index 0000000000..e0bfe611fc
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts
@@ -0,0 +1,36 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { UserLists } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+import { UserList } from '@/models/entities/user-list';
+
+export const meta = {
+ tags: ['lists'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ name: {
+ validator: $.str.range(1, 100)
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserList',
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const userList = await UserLists.insert({
+ id: genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ name: ps.name,
+ } as UserList).then(x => UserLists.findOneOrFail(x.identifiers[0]));
+
+ return await UserLists.pack(userList);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts
new file mode 100644
index 0000000000..5fe3bfb03d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserLists } from '@/models/index';
+
+export const meta = {
+ tags: ['lists'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ listId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '78436795-db79-42f5-b1e2-55ea2cf19166'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const userList = await UserLists.findOne({
+ id: ps.listId,
+ userId: user.id
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ await UserLists.delete(userList.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts
new file mode 100644
index 0000000000..cf0c92bb84
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts
@@ -0,0 +1,28 @@
+import define from '../../../define';
+import { UserLists } from '@/models/index';
+
+export const meta = {
+ tags: ['lists', 'account'],
+
+ requireCredential: true as const,
+
+ kind: 'read:account',
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserList',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const userLists = await UserLists.find({
+ userId: me.id,
+ });
+
+ return await Promise.all(userLists.map(x => UserLists.pack(x)));
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts
new file mode 100644
index 0000000000..d4357fc5e7
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts
@@ -0,0 +1,62 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import { publishUserListStream } from '@/services/stream';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { UserLists, UserListJoinings, Users } from '@/models/index';
+
+export const meta = {
+ tags: ['lists', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ listId: {
+ validator: $.type(ID),
+ },
+
+ userId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02'
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '588e7f72-c744-4a61-b180-d354e912bda2'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the list
+ const userList = await UserLists.findOne({
+ id: ps.listId,
+ userId: me.id,
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ // Fetch the user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ // Pull the user
+ await UserListJoinings.delete({ userListId: userList.id, userId: user.id });
+
+ publishUserListStream(userList.id, 'userRemoved', await Users.pack(user));
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts
new file mode 100644
index 0000000000..8e21059d3d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts
@@ -0,0 +1,92 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getUser } from '../../../common/getters';
+import { pushUserToUserList } from '@/services/user-list/push';
+import { UserLists, UserListJoinings, Blockings } from '@/models/index';
+
+export const meta = {
+ tags: ['lists', 'users'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ listId: {
+ validator: $.type(ID),
+ },
+
+ userId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '2214501d-ac96-4049-b717-91e42272a711'
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a'
+ },
+
+ alreadyAdded: {
+ message: 'That user has already been added to that list.',
+ code: 'ALREADY_ADDED',
+ id: '1de7c884-1595-49e9-857e-61f12f4d4fc5'
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot push this user because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the list
+ const userList = await UserLists.findOne({
+ id: ps.listId,
+ userId: me.id,
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ // Fetch the user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ // Check blocking
+ if (user.id !== me.id) {
+ const block = await Blockings.findOne({
+ blockerId: user.id,
+ blockeeId: me.id,
+ });
+ if (block) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+
+ const exist = await UserListJoinings.findOne({
+ userListId: userList.id,
+ userId: user.id
+ });
+
+ if (exist) {
+ throw new ApiError(meta.errors.alreadyAdded);
+ }
+
+ // Push the user
+ await pushUserToUserList(user, userList);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts
new file mode 100644
index 0000000000..f9a35cdab3
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts
@@ -0,0 +1,47 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserLists } from '@/models/index';
+
+export const meta = {
+ tags: ['lists', 'account'],
+
+ requireCredential: true as const,
+
+ kind: 'read:account',
+
+ params: {
+ listId: {
+ validator: $.type(ID),
+ },
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserList',
+ },
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Fetch the list
+ const userList = await UserLists.findOne({
+ id: ps.listId,
+ userId: me.id,
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ return await UserLists.pack(userList);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts
new file mode 100644
index 0000000000..1185af5043
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts
@@ -0,0 +1,55 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { UserLists } from '@/models/index';
+
+export const meta = {
+ tags: ['lists'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ listId: {
+ validator: $.type(ID),
+ },
+
+ name: {
+ validator: $.str.range(1, 100),
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'UserList',
+ },
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '796666fe-3dff-4d39-becb-8a5932c1d5b7'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ // Fetch the list
+ const userList = await UserLists.findOne({
+ id: ps.listId,
+ userId: user.id
+ });
+
+ if (userList == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ await UserLists.update(userList.id, {
+ name: ps.name
+ });
+
+ return await UserLists.pack(userList.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts
new file mode 100644
index 0000000000..0afbad9d04
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/notes.ts
@@ -0,0 +1,144 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { getUser } from '../../common/getters';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { Notes } from '@/models/index';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { Brackets } from 'typeorm';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['users', 'notes'],
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ includeReplies: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num,
+ },
+
+ untilDate: {
+ validator: $.optional.num,
+ },
+
+ includeMyRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ withFiles: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ fileType: {
+ validator: $.optional.arr($.str),
+ },
+
+ excludeNsfw: {
+ validator: $.optional.bool,
+ default: false,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Lookup user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ //#region Construct query
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere('note.userId = :userId', { userId: user.id })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, me);
+ if (me) generateMutedUserQuery(query, me, user);
+ if (me) generateBlockedUserQuery(query, me);
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+
+ if (ps.fileType != null) {
+ query.andWhere('note.fileIds != \'{}\'');
+ query.andWhere(new Brackets(qb => {
+ for (const type of ps.fileType!) {
+ const i = ps.fileType!.indexOf(type);
+ qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
+ }
+ }));
+
+ if (ps.excludeNsfw) {
+ query.andWhere('note.cw IS NULL');
+ query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
+ }
+ }
+
+ if (!ps.includeReplies) {
+ query.andWhere('note.replyId IS NULL');
+ }
+
+ if (ps.includeMyRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.userId != :userId', { userId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ //#endregion
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ return await Notes.packMany(timeline, me);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts
new file mode 100644
index 0000000000..24e9e207fd
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/pages.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { Pages } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+
+export const meta = {
+ tags: ['users', 'pages'],
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId)
+ .andWhere(`page.userId = :userId`, { userId: ps.userId })
+ .andWhere('page.visibility = \'public\'');
+
+ const pages = await query
+ .take(ps.limit!)
+ .getMany();
+
+ return await Pages.packMany(pages);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts
new file mode 100644
index 0000000000..fe5e4d84a9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/reactions.ts
@@ -0,0 +1,79 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { NoteReactions, UserProfiles } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { ApiError } from '../../error';
+
+export const meta = {
+ tags: ['users', 'reactions'],
+
+ requireCredential: false as const,
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num,
+ },
+
+ untilDate: {
+ validator: $.optional.num,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'NoteReaction',
+ }
+ },
+
+ errors: {
+ reactionsNotPublic: {
+ message: 'Reactions of the user is not public.',
+ code: 'REACTIONS_NOT_PUBLIC',
+ id: '673a7dd2-6924-1093-e0c0-e68456ceae5c'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const profile = await UserProfiles.findOneOrFail(ps.userId);
+
+ if (me == null || (me.id !== ps.userId && !profile.publicReactions)) {
+ throw new ApiError(meta.errors.reactionsNotPublic);
+ }
+
+ const query = makePaginationQuery(NoteReactions.createQueryBuilder('reaction'),
+ ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere(`reaction.userId = :userId`, { userId: ps.userId })
+ .leftJoinAndSelect('reaction.note', 'note');
+
+ generateVisibilityQuery(query, me);
+
+ const reactions = await query
+ .take(ps.limit!)
+ .getMany();
+
+ return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, me, { withNote: true })));
+});
diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts
new file mode 100644
index 0000000000..dde6bb1037
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts
@@ -0,0 +1,63 @@
+import * as ms from 'ms';
+import $ from 'cafy';
+import define from '../../define';
+import { Users, Followings } from '@/models/index';
+import { generateMutedUserQueryForUsers } from '../../common/generate-muted-user-query';
+import { generateBlockedUserQuery, generateBlockQueryForUsers } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: true as const,
+
+ kind: 'read:account',
+
+ params: {
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ offset: {
+ validator: $.optional.num.min(0),
+ default: 0
+ }
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'User',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const query = Users.createQueryBuilder('user')
+ .where('user.isLocked = FALSE')
+ .andWhere('user.isExplorable = TRUE')
+ .andWhere('user.host IS NULL')
+ .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) })
+ .andWhere('user.id != :meId', { meId: me.id })
+ .orderBy('user.followersCount', 'DESC');
+
+ generateMutedUserQueryForUsers(query, me);
+ generateBlockQueryForUsers(query, me);
+ generateBlockedUserQuery(query, me);
+
+ const followingQuery = Followings.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :followerId', { followerId: me.id });
+
+ query
+ .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`);
+
+ query.setParameters(followingQuery.getParameters());
+
+ const users = await query.take(ps.limit!).skip(ps.offset).getMany();
+
+ return await Users.packMany(users, me, { detail: true });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts
new file mode 100644
index 0000000000..32d76a5322
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/relation.ts
@@ -0,0 +1,111 @@
+import $ from 'cafy';
+import define from '../../define';
+import { ID } from '@/misc/cafy-id';
+import { Users } from '@/models/index';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: true as const,
+
+ params: {
+ userId: {
+ validator: $.either($.type(ID), $.arr($.type(ID)).unique()),
+ }
+ },
+
+ res: {
+ oneOf: [
+ {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ properties: {
+ id: {
+ type: 'string' as const,
+ optional: false as const, nullable: false as const,
+ format: 'id'
+ },
+ isFollowing: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ hasPendingFollowRequestFromYou: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ hasPendingFollowRequestToYou: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isFollowed: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isBlocking: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isBlocked: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isMuted: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ }
+ }
+ },
+ {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ properties: {
+ id: {
+ type: 'string' as const,
+ optional: false as const, nullable: false as const,
+ format: 'id'
+ },
+ isFollowing: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ hasPendingFollowRequestFromYou: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ hasPendingFollowRequestToYou: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isFollowed: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isBlocking: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isBlocked: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isMuted: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ }
+ }
+ }
+ }
+ ]
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId];
+
+ const relations = await Promise.all(ids.map(id => Users.getRelation(me.id, id)));
+
+ return Array.isArray(ps.userId) ? relations : relations[0];
+});
diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts
new file mode 100644
index 0000000000..2c8672cd47
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts
@@ -0,0 +1,90 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { publishAdminStream } from '@/services/stream';
+import { ApiError } from '../../error';
+import { getUser } from '../../common/getters';
+import { AbuseUserReports, Users } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: true as const,
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+
+ comment: {
+ validator: $.str.range(1, 2048),
+ },
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '1acefcb5-0959-43fd-9685-b48305736cb5'
+ },
+
+ cannotReportYourself: {
+ message: 'Cannot report yourself.',
+ code: 'CANNOT_REPORT_YOURSELF',
+ id: '1e13149e-b1e8-43cf-902e-c01dbfcb202f'
+ },
+
+ cannotReportAdmin: {
+ message: 'Cannot report the admin.',
+ code: 'CANNOT_REPORT_THE_ADMIN',
+ id: '35e166f5-05fb-4f87-a2d5-adb42676d48f'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ // Lookup user
+ const user = await getUser(ps.userId).catch(e => {
+ if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw e;
+ });
+
+ if (user.id === me.id) {
+ throw new ApiError(meta.errors.cannotReportYourself);
+ }
+
+ if (user.isAdmin) {
+ throw new ApiError(meta.errors.cannotReportAdmin);
+ }
+
+ const report = await AbuseUserReports.save({
+ id: genId(),
+ createdAt: new Date(),
+ targetUserId: user.id,
+ targetUserHost: user.host,
+ reporterId: me.id,
+ reporterHost: null,
+ comment: ps.comment,
+ });
+
+ // Publish event to moderators
+ setTimeout(async () => {
+ const moderators = await Users.find({
+ where: [{
+ isAdmin: true
+ }, {
+ isModerator: true
+ }]
+ });
+
+ for (const moderator of moderators) {
+ publishAdminStream(moderator.id, 'newAbuseUserReport', {
+ id: report.id,
+ targetUserId: report.targetUserId,
+ reporterId: report.reporterId,
+ comment: report.comment
+ });
+ }
+ }, 1);
+});
diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
new file mode 100644
index 0000000000..1ec5e1a743
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
@@ -0,0 +1,116 @@
+import $ from 'cafy';
+import define from '../../define';
+import { Followings, Users } from '@/models/index';
+import { Brackets } from 'typeorm';
+import { USER_ACTIVE_THRESHOLD } from '@/const';
+import { User } from '@/models/entities/user';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ username: {
+ validator: $.optional.nullable.str,
+ },
+
+ host: {
+ validator: $.optional.nullable.str,
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ detail: {
+ validator: $.optional.bool,
+ default: true,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'User',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
+
+ if (ps.host) {
+ const q = Users.createQueryBuilder('user')
+ .where('user.isSuspended = FALSE')
+ .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' });
+
+ if (ps.username) {
+ q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' });
+ }
+
+ q.andWhere('user.updatedAt IS NOT NULL');
+ q.orderBy('user.updatedAt', 'DESC');
+
+ const users = await q.take(ps.limit!).getMany();
+
+ return await Users.packMany(users, me, { detail: ps.detail });
+ } else if (ps.username) {
+ let users: User[] = [];
+
+ if (me) {
+ const followingQuery = Followings.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :followerId', { followerId: me.id });
+
+ const query = Users.createQueryBuilder('user')
+ .where(`user.id IN (${ followingQuery.getQuery() })`)
+ .andWhere(`user.id != :meId`, { meId: me.id })
+ .andWhere('user.isSuspended = FALSE')
+ .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' })
+ .andWhere(new Brackets(qb => { qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ }));
+
+ query.setParameters(followingQuery.getParameters());
+
+ users = await query
+ .orderBy('user.usernameLower', 'ASC')
+ .take(ps.limit!)
+ .getMany();
+
+ if (users.length < ps.limit!) {
+ const otherQuery = await Users.createQueryBuilder('user')
+ .where(`user.id NOT IN (${ followingQuery.getQuery() })`)
+ .andWhere(`user.id != :meId`, { meId: me.id })
+ .andWhere('user.isSuspended = FALSE')
+ .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' })
+ .andWhere('user.updatedAt IS NOT NULL');
+
+ otherQuery.setParameters(followingQuery.getParameters());
+
+ const otherUsers = await otherQuery
+ .orderBy('user.updatedAt', 'DESC')
+ .take(ps.limit! - users.length)
+ .getMany();
+
+ users = users.concat(otherUsers);
+ }
+ } else {
+ users = await Users.createQueryBuilder('user')
+ .where('user.isSuspended = FALSE')
+ .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' })
+ .andWhere('user.updatedAt IS NOT NULL')
+ .orderBy('user.updatedAt', 'DESC')
+ .take(ps.limit! - users.length)
+ .getMany();
+ }
+
+ return await Users.packMany(users, me, { detail: ps.detail });
+ }
+});
diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts
new file mode 100644
index 0000000000..9aa988d9ed
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/search.ts
@@ -0,0 +1,127 @@
+import $ from 'cafy';
+import define from '../../define';
+import { UserProfiles, Users } from '@/models/index';
+import { User } from '@/models/entities/user';
+import { Brackets } from 'typeorm';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ query: {
+ validator: $.str,
+ },
+
+ offset: {
+ validator: $.optional.num.min(0),
+ default: 0,
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ origin: {
+ validator: $.optional.str.or(['local', 'remote', 'combined']),
+ default: 'combined',
+ },
+
+ detail: {
+ validator: $.optional.bool,
+ default: true,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'User',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
+
+ const isUsername = ps.query.startsWith('@');
+
+ let users: User[] = [];
+
+ if (isUsername) {
+ const usernameQuery = Users.createQueryBuilder('user')
+ .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' })
+ .andWhere(new Brackets(qb => { qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ }))
+ .andWhere('user.isSuspended = FALSE');
+
+ if (ps.origin === 'local') {
+ usernameQuery.andWhere('user.host IS NULL');
+ } else if (ps.origin === 'remote') {
+ usernameQuery.andWhere('user.host IS NOT NULL');
+ }
+
+ users = await usernameQuery
+ .orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
+ .take(ps.limit!)
+ .skip(ps.offset)
+ .getMany();
+ } else {
+ const nameQuery = Users.createQueryBuilder('user')
+ .where('user.name ILIKE :query', { query: '%' + ps.query + '%' })
+ .andWhere(new Brackets(qb => { qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ }))
+ .andWhere('user.isSuspended = FALSE');
+
+ if (ps.origin === 'local') {
+ nameQuery.andWhere('user.host IS NULL');
+ } else if (ps.origin === 'remote') {
+ nameQuery.andWhere('user.host IS NOT NULL');
+ }
+
+ users = await nameQuery
+ .orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
+ .take(ps.limit!)
+ .skip(ps.offset)
+ .getMany();
+
+ if (users.length < ps.limit!) {
+ const profQuery = UserProfiles.createQueryBuilder('prof')
+ .select('prof.userId')
+ .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' });
+
+ if (ps.origin === 'local') {
+ profQuery.andWhere('prof.userHost IS NULL');
+ } else if (ps.origin === 'remote') {
+ profQuery.andWhere('prof.userHost IS NOT NULL');
+ }
+
+ const query = Users.createQueryBuilder('user')
+ .where(`user.id IN (${ profQuery.getQuery() })`)
+ .andWhere(new Brackets(qb => { qb
+ .where('user.updatedAt IS NULL')
+ .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
+ }))
+ .andWhere('user.isSuspended = FALSE')
+ .setParameters(profQuery.getParameters());
+
+ users = users.concat(await query
+ .orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
+ .take(ps.limit!)
+ .skip(ps.offset)
+ .getMany()
+ );
+ }
+ }
+
+ return await Users.packMany(users, me, { detail: ps.detail });
+});
diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts
new file mode 100644
index 0000000000..f056983636
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/show.ts
@@ -0,0 +1,105 @@
+import $ from 'cafy';
+import { resolveUser } from '@/remote/resolve-user';
+import define from '../../define';
+import { apiLogger } from '../../logger';
+import { ApiError } from '../../error';
+import { ID } from '@/misc/cafy-id';
+import { Users } from '@/models/index';
+import { In } from 'typeorm';
+import { User } from '@/models/entities/user';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ userId: {
+ validator: $.optional.type(ID),
+ },
+
+ userIds: {
+ validator: $.optional.arr($.type(ID)).unique(),
+ },
+
+ username: {
+ validator: $.optional.str
+ },
+
+ host: {
+ validator: $.optional.nullable.str
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'User',
+ },
+
+ errors: {
+ failedToResolveRemoteUser: {
+ message: 'Failed to resolve remote user.',
+ code: 'FAILED_TO_RESOLVE_REMOTE_USER',
+ id: 'ef7b9be4-9cba-4e6f-ab41-90ed171c7d3c',
+ kind: 'server' as const
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '4362f8dc-731f-4ad8-a694-be5a88922a24'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ let user;
+
+ const isAdminOrModerator = me && (me.isAdmin || me.isModerator);
+
+ if (ps.userIds) {
+ if (ps.userIds.length === 0) {
+ return [];
+ }
+
+ const users = await Users.find(isAdminOrModerator ? {
+ id: In(ps.userIds)
+ } : {
+ id: In(ps.userIds),
+ isSuspended: false
+ });
+
+ // リクエストされた通りに並べ替え
+ const _users: User[] = [];
+ for (const id of ps.userIds) {
+ _users.push(users.find(x => x.id === id)!);
+ }
+
+ return await Promise.all(_users.map(u => Users.pack(u, me, {
+ detail: true
+ })));
+ } else {
+ // Lookup user
+ if (typeof ps.host === 'string' && typeof ps.username === 'string') {
+ user = await resolveUser(ps.username, ps.host).catch(e => {
+ apiLogger.warn(`failed to resolve remote user: ${e}`);
+ throw new ApiError(meta.errors.failedToResolveRemoteUser);
+ });
+ } else {
+ const q: any = ps.userId != null
+ ? { id: ps.userId }
+ : { usernameLower: ps.username!.toLowerCase(), host: null };
+
+ user = await Users.findOne(q);
+ }
+
+ if (user == null || (!isAdminOrModerator && user.isSuspended)) {
+ throw new ApiError(meta.errors.noSuchUser);
+ }
+
+ return await Users.pack(user, me, {
+ detail: true
+ });
+ }
+});
diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts
new file mode 100644
index 0000000000..ef8afd5625
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/stats.ts
@@ -0,0 +1,144 @@
+import $ from 'cafy';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { ID } from '@/misc/cafy-id';
+import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, ReversiGames, Users } from '@/models/index';
+
+export const meta = {
+ tags: ['users'],
+
+ requireCredential: false as const,
+
+ params: {
+ userId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '9e638e45-3b25-4ef7-8f95-07e8498f1819'
+ },
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const user = await Users.findOne(ps.userId);
+ if (user == null) {
+ throw new ApiError(meta.errors.noSuchUser);
+ }
+
+ const [
+ notesCount,
+ repliesCount,
+ renotesCount,
+ repliedCount,
+ renotedCount,
+ pollVotesCount,
+ pollVotedCount,
+ localFollowingCount,
+ remoteFollowingCount,
+ localFollowersCount,
+ remoteFollowersCount,
+ sentReactionsCount,
+ receivedReactionsCount,
+ noteFavoritesCount,
+ pageLikesCount,
+ pageLikedCount,
+ driveFilesCount,
+ driveUsage,
+ reversiCount,
+ ] = await Promise.all([
+ Notes.createQueryBuilder('note')
+ .where('note.userId = :userId', { userId: user.id })
+ .getCount(),
+ Notes.createQueryBuilder('note')
+ .where('note.userId = :userId', { userId: user.id })
+ .andWhere('note.replyId IS NOT NULL')
+ .getCount(),
+ Notes.createQueryBuilder('note')
+ .where('note.userId = :userId', { userId: user.id })
+ .andWhere('note.renoteId IS NOT NULL')
+ .getCount(),
+ Notes.createQueryBuilder('note')
+ .where('note.replyUserId = :userId', { userId: user.id })
+ .getCount(),
+ Notes.createQueryBuilder('note')
+ .where('note.renoteUserId = :userId', { userId: user.id })
+ .getCount(),
+ PollVotes.createQueryBuilder('vote')
+ .where('vote.userId = :userId', { userId: user.id })
+ .getCount(),
+ PollVotes.createQueryBuilder('vote')
+ .innerJoin('vote.note', 'note')
+ .where('note.userId = :userId', { userId: user.id })
+ .getCount(),
+ Followings.createQueryBuilder('following')
+ .where('following.followerId = :userId', { userId: user.id })
+ .andWhere('following.followeeHost IS NULL')
+ .getCount(),
+ Followings.createQueryBuilder('following')
+ .where('following.followerId = :userId', { userId: user.id })
+ .andWhere('following.followeeHost IS NOT NULL')
+ .getCount(),
+ Followings.createQueryBuilder('following')
+ .where('following.followeeId = :userId', { userId: user.id })
+ .andWhere('following.followerHost IS NULL')
+ .getCount(),
+ Followings.createQueryBuilder('following')
+ .where('following.followeeId = :userId', { userId: user.id })
+ .andWhere('following.followerHost IS NOT NULL')
+ .getCount(),
+ NoteReactions.createQueryBuilder('reaction')
+ .where('reaction.userId = :userId', { userId: user.id })
+ .getCount(),
+ NoteReactions.createQueryBuilder('reaction')
+ .innerJoin('reaction.note', 'note')
+ .where('note.userId = :userId', { userId: user.id })
+ .getCount(),
+ NoteFavorites.createQueryBuilder('favorite')
+ .where('favorite.userId = :userId', { userId: user.id })
+ .getCount(),
+ PageLikes.createQueryBuilder('like')
+ .where('like.userId = :userId', { userId: user.id })
+ .getCount(),
+ PageLikes.createQueryBuilder('like')
+ .innerJoin('like.page', 'page')
+ .where('page.userId = :userId', { userId: user.id })
+ .getCount(),
+ DriveFiles.createQueryBuilder('file')
+ .where('file.userId = :userId', { userId: user.id })
+ .getCount(),
+ DriveFiles.calcDriveUsageOf(user),
+ ReversiGames.createQueryBuilder('game')
+ .where('game.user1Id = :userId', { userId: user.id })
+ .orWhere('game.user2Id = :userId', { userId: user.id })
+ .getCount(),
+ ]);
+
+ return {
+ notesCount,
+ repliesCount,
+ renotesCount,
+ repliedCount,
+ renotedCount,
+ pollVotesCount,
+ pollVotedCount,
+ localFollowingCount,
+ remoteFollowingCount,
+ localFollowersCount,
+ remoteFollowersCount,
+ followingCount: localFollowingCount + remoteFollowingCount,
+ followersCount: localFollowersCount + remoteFollowersCount,
+ sentReactionsCount,
+ receivedReactionsCount,
+ noteFavoritesCount,
+ pageLikesCount,
+ pageLikedCount,
+ driveFilesCount,
+ driveUsage,
+ reversiCount,
+ };
+});