summaryrefslogtreecommitdiff
path: root/src/server/api/endpoints/users
diff options
context:
space:
mode:
authorha-dai <contact@haradai.net>2018-05-04 02:49:46 +0900
committerha-dai <contact@haradai.net>2018-05-04 02:49:46 +0900
commitf850283147072c681df1b39c57f8bd0b14f18016 (patch)
tree63ff533c91097da2d8ca2070fc67a28f67ee33da /src/server/api/endpoints/users
parentMerge branch 'master' of github.com:syuilo/misskey (diff)
parent1.7.0 (diff)
downloadmisskey-f850283147072c681df1b39c57f8bd0b14f18016.tar.gz
misskey-f850283147072c681df1b39c57f8bd0b14f18016.tar.bz2
misskey-f850283147072c681df1b39c57f8bd0b14f18016.zip
Merge branch 'master' of github.com:syuilo/misskey
Diffstat (limited to 'src/server/api/endpoints/users')
-rw-r--r--src/server/api/endpoints/users/followers.ts87
-rw-r--r--src/server/api/endpoints/users/following.ts91
-rw-r--r--src/server/api/endpoints/users/get_frequently_replied_users.ts99
-rw-r--r--src/server/api/endpoints/users/lists/create.ts25
-rw-r--r--src/server/api/endpoints/users/lists/list.ts13
-rw-r--r--src/server/api/endpoints/users/lists/push.ts61
-rw-r--r--src/server/api/endpoints/users/lists/show.ts23
-rw-r--r--src/server/api/endpoints/users/notes.ts133
-rw-r--r--src/server/api/endpoints/users/recommendation.ts57
-rw-r--r--src/server/api/endpoints/users/search.ts98
-rw-r--r--src/server/api/endpoints/users/search_by_username.ts34
-rw-r--r--src/server/api/endpoints/users/show.ts68
12 files changed, 789 insertions, 0 deletions
diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts
new file mode 100644
index 0000000000..810cd7341b
--- /dev/null
+++ b/src/server/api/endpoints/users/followers.ts
@@ -0,0 +1,87 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import User from '../../../../models/user';
+import Following from '../../../../models/following';
+import { pack } from '../../../../models/user';
+import { getFriendIds } from '../../common/get-friends';
+
+/**
+ * Get followers of a user
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Get 'iknow' parameter
+ const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow);
+ if (iknowErr) return rej('invalid iknow param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'cursor' parameter
+ const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor);
+ if (cursorErr) return rej('invalid cursor param');
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: userId
+ }, {
+ fields: {
+ _id: true
+ }
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Construct query
+ const query = {
+ followeeId: user._id
+ } as any;
+
+ // ログインしていてかつ iknow フラグがあるとき
+ if (me && iknow) {
+ // Get my friends
+ const myFriends = await getFriendIds(me._id);
+
+ query.followerId = {
+ $in: myFriends
+ };
+ }
+
+ // カーソルが指定されている場合
+ if (cursor) {
+ query._id = {
+ $lt: cursor
+ };
+ }
+
+ // Get followers
+ const following = await Following
+ .find(query, {
+ limit: limit + 1,
+ sort: { _id: -1 }
+ });
+
+ // 「次のページ」があるかどうか
+ const inStock = following.length === limit + 1;
+ if (inStock) {
+ following.pop();
+ }
+
+ // Serialize
+ const users = await Promise.all(following.map(async f =>
+ await pack(f.followerId, me, { detail: true })));
+
+ // Response
+ res({
+ users: users,
+ next: inStock ? following[following.length - 1]._id : null,
+ });
+});
diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts
new file mode 100644
index 0000000000..3373b9d632
--- /dev/null
+++ b/src/server/api/endpoints/users/following.ts
@@ -0,0 +1,91 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import User from '../../../../models/user';
+import Following from '../../../../models/following';
+import { pack } from '../../../../models/user';
+import { getFriendIds } from '../../common/get-friends';
+
+/**
+ * Get following users of a user
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Get 'iknow' parameter
+ const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow);
+ if (iknowErr) return rej('invalid iknow param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'cursor' parameter
+ const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor);
+ if (cursorErr) return rej('invalid cursor param');
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: userId
+ }, {
+ fields: {
+ _id: true
+ }
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Construct query
+ const query = {
+ followerId: user._id
+ } as any;
+
+ // ログインしていてかつ iknow フラグがあるとき
+ if (me && iknow) {
+ // Get my friends
+ const myFriends = await getFriendIds(me._id);
+
+ query.followeeId = {
+ $in: myFriends
+ };
+ }
+
+ // カーソルが指定されている場合
+ if (cursor) {
+ query._id = {
+ $lt: cursor
+ };
+ }
+
+ // Get followers
+ const following = await Following
+ .find(query, {
+ limit: limit + 1,
+ sort: { _id: -1 }
+ });
+
+ // 「次のページ」があるかどうか
+ const inStock = following.length === limit + 1;
+ if (inStock) {
+ following.pop();
+ }
+
+ // Serialize
+ const users = await Promise.all(following.map(async f =>
+ await pack(f.followeeId, me, { detail: true })));
+
+ // Response
+ res({
+ users: users,
+ next: inStock ? following[following.length - 1]._id : null,
+ });
+});
diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts
new file mode 100644
index 0000000000..64d737a06b
--- /dev/null
+++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts
@@ -0,0 +1,99 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import Note from '../../../../models/note';
+import User, { pack } from '../../../../models/user';
+
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) return rej('invalid limit param');
+
+ // Lookup user
+ const user = await User.findOne({
+ _id: userId
+ }, {
+ fields: {
+ _id: true
+ }
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ // Fetch recent notes
+ const recentNotes = await Note.find({
+ userId: user._id,
+ replyId: {
+ $exists: true,
+ $ne: null
+ }
+ }, {
+ sort: {
+ _id: -1
+ },
+ limit: 1000,
+ fields: {
+ _id: false,
+ replyId: true
+ }
+ });
+
+ // 投稿が少なかったら中断
+ if (recentNotes.length === 0) {
+ return res([]);
+ }
+
+ const replyTargetNotes = await Note.find({
+ _id: {
+ $in: recentNotes.map(p => p.replyId)
+ },
+ userId: {
+ $ne: user._id
+ }
+ }, {
+ fields: {
+ _id: false,
+ userId: true
+ }
+ });
+
+ const repliedUsers = {};
+
+ // Extract replies from recent notes
+ replyTargetNotes.forEach(note => {
+ const userId = note.userId.toString();
+ if (repliedUsers[userId]) {
+ repliedUsers[userId]++;
+ } else {
+ repliedUsers[userId] = 1;
+ }
+ });
+
+ // Calc peak
+ let peak = 0;
+ Object.keys(repliedUsers).forEach(user => {
+ if (repliedUsers[user] > peak) peak = repliedUsers[user];
+ });
+
+ // 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, limit);
+
+ // Make replies object (includes weights)
+ const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({
+ user: await pack(user, me, { detail: true }),
+ weight: repliedUsers[user] / peak
+ })));
+
+ // Response
+ res(repliesObj);
+});
diff --git a/src/server/api/endpoints/users/lists/create.ts b/src/server/api/endpoints/users/lists/create.ts
new file mode 100644
index 0000000000..100a78b872
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/create.ts
@@ -0,0 +1,25 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Create a user list
+ */
+module.exports = async (params, user) => new Promise(async (res, rej) => {
+ // Get 'title' parameter
+ const [title, titleErr] = $.str.range(1, 100).get(params.title);
+ if (titleErr) return rej('invalid title param');
+
+ // insert
+ const userList = await UserList.insert({
+ createdAt: new Date(),
+ userId: user._id,
+ title: title,
+ userIds: []
+ });
+
+ // Response
+ res(await pack(userList));
+});
diff --git a/src/server/api/endpoints/users/lists/list.ts b/src/server/api/endpoints/users/lists/list.ts
new file mode 100644
index 0000000000..d19339a1f5
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/list.ts
@@ -0,0 +1,13 @@
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Add a user to a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+ // Fetch lists
+ const userLists = await UserList.find({
+ userId: me._id,
+ });
+
+ res(await Promise.all(userLists.map(x => pack(x))));
+});
diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts
new file mode 100644
index 0000000000..da5a9a134c
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/push.ts
@@ -0,0 +1,61 @@
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
+import UserList from '../../../../../models/user-list';
+import User, { pack as packUser, isRemoteUser, getGhost } from '../../../../../models/user';
+import { publishUserListStream } from '../../../../../publishers/stream';
+import ap from '../../../../../remote/activitypub/renderer';
+import renderFollow from '../../../../../remote/activitypub/renderer/follow';
+import { deliver } from '../../../../../queue';
+
+/**
+ * Add a user to a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+ // Get 'listId' parameter
+ const [listId, listIdErr] = $.type(ID).get(params.listId);
+ if (listIdErr) return rej('invalid listId param');
+
+ // Fetch the list
+ const userList = await UserList.findOne({
+ _id: listId,
+ userId: me._id,
+ });
+
+ if (userList == null) {
+ return rej('list not found');
+ }
+
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Fetch the user
+ const user = await User.findOne({
+ _id: userId
+ });
+
+ if (user == null) {
+ return rej('user not found');
+ }
+
+ if (userList.userIds.map(id => id.toHexString()).includes(user._id.toHexString())) {
+ return rej('the user already added');
+ }
+
+ // Push the user
+ await UserList.update({ _id: userList._id }, {
+ $push: {
+ userIds: user._id
+ }
+ });
+
+ res();
+
+ publishUserListStream(userList._id, 'userAdded', await packUser(user));
+
+ // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
+ if (isRemoteUser(user)) {
+ const ghost = await getGhost();
+ const content = ap(renderFollow(ghost, user));
+ deliver(ghost, content, user.inbox);
+ }
+});
diff --git a/src/server/api/endpoints/users/lists/show.ts b/src/server/api/endpoints/users/lists/show.ts
new file mode 100644
index 0000000000..16cb3382fd
--- /dev/null
+++ b/src/server/api/endpoints/users/lists/show.ts
@@ -0,0 +1,23 @@
+import $ from 'cafy'; import ID from '../../../../../cafy-id';
+import UserList, { pack } from '../../../../../models/user-list';
+
+/**
+ * Show a user list
+ */
+module.exports = async (params, me) => new Promise(async (res, rej) => {
+ // Get 'listId' parameter
+ const [listId, listIdErr] = $.type(ID).get(params.listId);
+ if (listIdErr) return rej('invalid listId param');
+
+ // Fetch the list
+ const userList = await UserList.findOne({
+ _id: listId,
+ userId: me._id,
+ });
+
+ if (userList == null) {
+ return rej('list not found');
+ }
+
+ res(await pack(userList));
+});
diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts
new file mode 100644
index 0000000000..061c363d0f
--- /dev/null
+++ b/src/server/api/endpoints/users/notes.ts
@@ -0,0 +1,133 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import getHostLower from '../../common/get-host-lower';
+import Note, { pack } from '../../../../models/note';
+import User from '../../../../models/user';
+
+/**
+ * Get notes of a user
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).optional().get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Get 'username' parameter
+ const [username, usernameErr] = $.str.optional().get(params.username);
+ if (usernameErr) return rej('invalid username param');
+
+ if (userId === undefined && username === undefined) {
+ return rej('userId or pair of username and host is required');
+ }
+
+ // Get 'host' parameter
+ const [host, hostErr] = $.str.optional().get(params.host);
+ if (hostErr) return rej('invalid host param');
+
+ if (userId === undefined && host === undefined) {
+ return rej('userId or pair of username and host is required');
+ }
+
+ // Get 'includeReplies' parameter
+ const [includeReplies = true, includeRepliesErr] = $.bool.optional().get(params.includeReplies);
+ if (includeRepliesErr) return rej('invalid includeReplies param');
+
+ // Get 'withMedia' parameter
+ const [withMedia = false, withMediaErr] = $.bool.optional().get(params.withMedia);
+ if (withMediaErr) return rej('invalid withMedia param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'sinceId' parameter
+ const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId);
+ if (sinceIdErr) return rej('invalid sinceId param');
+
+ // Get 'untilId' parameter
+ const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId);
+ if (untilIdErr) return rej('invalid untilId param');
+
+ // Get 'sinceDate' parameter
+ const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate);
+ if (sinceDateErr) throw 'invalid sinceDate param';
+
+ // Get 'untilDate' parameter
+ const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate);
+ if (untilDateErr) throw 'invalid untilDate param';
+
+ // Check if only one of sinceId, untilId, sinceDate, untilDate specified
+ if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
+ throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
+ }
+
+ const q = userId !== undefined
+ ? { _id: userId }
+ : { usernameLower: username.toLowerCase(), host: getHostLower(host) } ;
+
+ // Lookup user
+ const user = await User.findOne(q, {
+ fields: {
+ _id: true
+ }
+ });
+
+ if (user === null) {
+ return rej('user not found');
+ }
+
+ //#region Construct query
+ const sort = {
+ _id: -1
+ };
+
+ const query = {
+ userId: user._id
+ } as any;
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (untilId) {
+ query._id = {
+ $lt: untilId
+ };
+ } else if (sinceDate) {
+ sort._id = 1;
+ query.createdAt = {
+ $gt: new Date(sinceDate)
+ };
+ } else if (untilDate) {
+ query.createdAt = {
+ $lt: new Date(untilDate)
+ };
+ }
+
+ if (!includeReplies) {
+ query.replyId = null;
+ }
+
+ if (withMedia) {
+ query.mediaIds = {
+ $exists: true,
+ $ne: []
+ };
+ }
+ //#endregion
+
+ // Issue query
+ const notes = await Note
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(notes.map(async (note) =>
+ await pack(note, me)
+ )));
+});
diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts
new file mode 100644
index 0000000000..620ae17ca2
--- /dev/null
+++ b/src/server/api/endpoints/users/recommendation.ts
@@ -0,0 +1,57 @@
+/**
+ * Module dependencies
+ */
+const ms = require('ms');
+import $ from 'cafy';
+import User, { pack } from '../../../../models/user';
+import { getFriendIds } from '../../common/get-friends';
+import Mute from '../../../../models/mute';
+
+/**
+ * Get recommended users
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
+ if (offsetErr) return rej('invalid offset param');
+
+ // ID list of the user itself and other users who the user follows
+ const followingIds = await getFriendIds(me._id);
+
+ // ミュートしているユーザーを取得
+ const mutedUserIds = (await Mute.find({
+ muterId: me._id
+ })).map(m => m.muteeId);
+
+ const users = await User
+ .find({
+ _id: {
+ $nin: followingIds.concat(mutedUserIds)
+ },
+ $or: [{
+ lastUsedAt: {
+ $gte: new Date(Date.now() - ms('7days'))
+ }
+ }, {
+ host: null
+ }]
+ }, {
+ limit: limit,
+ skip: offset,
+ sort: {
+ followersCount: -1
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await pack(user, me, { detail: true }))));
+});
diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts
new file mode 100644
index 0000000000..cfbdc337bf
--- /dev/null
+++ b/src/server/api/endpoints/users/search.ts
@@ -0,0 +1,98 @@
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import $ from 'cafy';
+import User, { pack } from '../../../../models/user';
+import config from '../../../../config';
+const escapeRegexp = require('escape-regexp');
+
+/**
+ * Search a user
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'query' parameter
+ const [query, queryError] = $.str.pipe(x => x != '').get(params.query);
+ if (queryError) return rej('invalid query param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'max' parameter
+ const [max = 10, maxErr] = $.num.optional().range(1, 30).get(params.max);
+ if (maxErr) return rej('invalid max param');
+
+ // If Elasticsearch is available, search by $
+ // If not, search by MongoDB
+ (config.elasticsearch.enable ? byElasticsearch : byNative)
+ (res, rej, me, query, offset, max);
+});
+
+// Search by MongoDB
+async function byNative(res, rej, me, query, offset, max) {
+ const escapedQuery = escapeRegexp(query);
+
+ // Search users
+ const users = await User
+ .find({
+ $or: [{
+ usernameLower: new RegExp(escapedQuery.replace('@', '').toLowerCase())
+ }, {
+ name: new RegExp(escapedQuery)
+ }]
+ }, {
+ limit: max
+ });
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await pack(user, me, { detail: true }))));
+}
+
+// Search by Elasticsearch
+async function byElasticsearch(res, rej, me, query, offset, max) {
+ const es = require('../../db/elasticsearch');
+
+ es.search({
+ index: 'misskey',
+ type: 'user',
+ body: {
+ size: max,
+ from: offset,
+ query: {
+ simple_query_string: {
+ fields: ['username', 'name', 'bio'],
+ query: query,
+ default_operator: 'and'
+ }
+ }
+ }
+ }, async (error, response) => {
+ if (error) {
+ console.error(error);
+ return res(500);
+ }
+
+ if (response.hits.total === 0) {
+ return res([]);
+ }
+
+ const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id));
+
+ const users = await User
+ .find({
+ _id: {
+ $in: hits
+ }
+ });
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await pack(user, me, { detail: true }))));
+ });
+}
diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts
new file mode 100644
index 0000000000..5927d00faf
--- /dev/null
+++ b/src/server/api/endpoints/users/search_by_username.ts
@@ -0,0 +1,34 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import User, { pack } from '../../../../models/user';
+
+/**
+ * Search a user by username
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'query' parameter
+ const [query, queryError] = $.str.get(params.query);
+ if (queryError) return rej('invalid query param');
+
+ // Get 'offset' parameter
+ const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset);
+ if (offsetErr) return rej('invalid offset param');
+
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit);
+ if (limitErr) return rej('invalid limit param');
+
+ const users = await User
+ .find({
+ usernameLower: new RegExp(query.toLowerCase())
+ }, {
+ limit: limit,
+ skip: offset
+ });
+
+ // Serialize
+ res(await Promise.all(users.map(async user =>
+ await pack(user, me, { detail: true }))));
+});
diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
new file mode 100644
index 0000000000..b8c6ff25c4
--- /dev/null
+++ b/src/server/api/endpoints/users/show.ts
@@ -0,0 +1,68 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy'; import ID from '../../../../cafy-id';
+import User, { pack } from '../../../../models/user';
+import resolveRemoteUser from '../../../../remote/resolve-user';
+
+const cursorOption = { fields: { data: false } };
+
+/**
+ * Show user(s)
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ let user;
+
+ // Get 'userId' parameter
+ const [userId, userIdErr] = $.type(ID).optional().get(params.userId);
+ if (userIdErr) return rej('invalid userId param');
+
+ // Get 'userIds' parameter
+ const [userIds, userIdsErr] = $.arr($.type(ID)).optional().get(params.userIds);
+ if (userIdsErr) return rej('invalid userIds param');
+
+ // Get 'username' parameter
+ const [username, usernameErr] = $.str.optional().get(params.username);
+ if (usernameErr) return rej('invalid username param');
+
+ // Get 'host' parameter
+ const [host, hostErr] = $.str.optional().nullable().get(params.host);
+ if (hostErr) return rej('invalid host param');
+
+ if (userIds) {
+ const users = await User.find({
+ _id: {
+ $in: userIds
+ }
+ });
+
+ res(await Promise.all(users.map(u => pack(u, me, {
+ detail: true
+ }))));
+ } else {
+ // Lookup user
+ if (typeof host === 'string') {
+ try {
+ user = await resolveRemoteUser(username, host, cursorOption);
+ } catch (e) {
+ console.warn(`failed to resolve remote user: ${e}`);
+ return rej('failed to resolve remote user');
+ }
+ } else {
+ const q = userId !== undefined
+ ? { _id: userId }
+ : { usernameLower: username.toLowerCase(), host: null };
+
+ user = await User.findOne(q, cursorOption);
+
+ if (user === null) {
+ return rej('user not found');
+ }
+ }
+
+ // Send response
+ res(await pack(user, me, {
+ detail: true
+ }));
+ }
+});