diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-01-30 04:37:25 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-30 04:37:25 +0900 |
| commit | f6154dc0af1a0d65819e87240f4385f9573095cb (patch) | |
| tree | 699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/server | |
| parent | Add Event activity-type support (#5785) (diff) | |
| download | sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2 sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip | |
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/server')
49 files changed, 1700 insertions, 369 deletions
diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts index c8d43ba286..f686446c5c 100644 --- a/src/server/api/common/read-notification.ts +++ b/src/server/api/common/read-notification.ts @@ -1,8 +1,8 @@ import { publishMainStream } from '../../../services/stream'; import { User } from '../../../models/entities/user'; import { Notification } from '../../../models/entities/notification'; -import { Mutings, Notifications } from '../../../models'; -import { In, Not } from 'typeorm'; +import { Notifications, Users } from '../../../models'; +import { In } from 'typeorm'; /** * Mark notifications as read @@ -11,11 +11,6 @@ export async function readNotification( userId: User['id'], notificationIds: Notification['id'][] ) { - const mute = await Mutings.find({ - muterId: userId - }); - const mutedUserIds = mute.map(m => m.muteeId); - // Update documents await Notifications.update({ id: In(notificationIds), @@ -24,14 +19,7 @@ export async function readNotification( isRead: true }); - // Calc count of my unread notifications - const count = await Notifications.count({ - notifieeId: userId, - ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), - isRead: false - }); - - if (count === 0) { + if (!await Users.getHasUnreadNotification(userId)) { // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 publishMainStream(userId, 'readAllNotifications'); } diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts index 66f89182d2..aa2786f8fc 100644 --- a/src/server/api/common/signin.ts +++ b/src/server/api/common/signin.ts @@ -24,7 +24,10 @@ export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { ctx.redirect(config.url); } else { - ctx.body = { i: user.token }; + ctx.body = { + id: user.id, + i: user.token + }; ctx.status = 200; } diff --git a/src/server/api/common/signup.ts b/src/server/api/common/signup.ts new file mode 100644 index 0000000000..f0eb27e5e4 --- /dev/null +++ b/src/server/api/common/signup.ts @@ -0,0 +1,104 @@ +import * as bcrypt from 'bcryptjs'; +import { generateKeyPair } from 'crypto'; +import generateUserToken from './generate-native-user-token'; +import { User } from '../../../models/entities/user'; +import { Users, UsedUsernames } from '../../../models'; +import { UserProfile } from '../../../models/entities/user-profile'; +import { getConnection } from 'typeorm'; +import { genId } from '../../../misc/gen-id'; +import { toPunyNullable } from '../../../misc/convert-host'; +import { UserKeypair } from '../../../models/entities/user-keypair'; +import { usersChart } from '../../../services/chart'; +import { UsedUsername } from '../../../models/entities/used-username'; + +export async function signup(username: User['username'], password: UserProfile['password'], host: string | null = null) { + // Validate username + if (!Users.validateLocalUsername.ok(username)) { + throw new Error('INVALID_USERNAME'); + } + + // Validate password + if (!Users.validatePassword.ok(password)) { + throw new Error('INVALID_PASSWORD'); + } + + const usersCount = await Users.count({}); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if (await Users.findOne({ usernameLower: username.toLowerCase(), host: null })) { + throw new Error('DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await UsedUsernames.findOne({ username: username.toLowerCase() })) { + throw new Error('USED_USERNAME'); + } + + const keyPair = await new Promise<string[]>((res, rej) => + generateKeyPair('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + } as any, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]) + )); + + let account!: User; + + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOne(User, { + usernameLower: username.toLowerCase(), + host: null + }); + + if (exist) throw new Error(' the username is already used'); + + account = await transactionalEntityManager.save(new User({ + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: toPunyNullable(host), + token: secret, + isAdmin: usersCount === 0, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: account.id + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: true, + autoWatch: false, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + usersChart.update(account, true); + + return { account, secret }; +} diff --git a/src/server/api/endpoints/admin/accounts/create.ts b/src/server/api/endpoints/admin/accounts/create.ts new file mode 100644 index 0000000000..ac80b579b7 --- /dev/null +++ b/src/server/api/endpoints/admin/accounts/create.ts @@ -0,0 +1,33 @@ +import define from '../../../define'; +import { Users } from '../../../../../models'; +import { signup } from '../../../common/signup'; + +export const meta = { + tags: ['admin'], + + params: { + username: { + validator: Users.validateLocalUsername, + }, + + password: { + validator: Users.validatePassword, + } + } +}; + +export default define(meta, async (ps, me) => { + const noUsers = (await Users.count({})) === 0; + if (!noUsers && me == null) throw new Error('access denied'); + + const { account, secret } = await signup(ps.username, ps.password); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true + }); + + (res as any).token = secret; + + return res; +}); diff --git a/src/server/api/endpoints/admin/announcements/create.ts b/src/server/api/endpoints/admin/announcements/create.ts new file mode 100644 index 0000000000..c1d48a7d38 --- /dev/null +++ b/src/server/api/endpoints/admin/announcements/create.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Announcements } from '../../../../../models'; +import { genId } from '../../../../../misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + } +}; + +export default define(meta, async (ps) => { + const announcement = await Announcements.save({ + id: genId(), + createdAt: new Date(), + updatedAt: null, + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); + + return announcement; +}); diff --git a/src/server/api/endpoints/admin/announcements/delete.ts b/src/server/api/endpoints/admin/announcements/delete.ts new file mode 100644 index 0000000000..284b4bf549 --- /dev/null +++ b/src/server/api/endpoints/admin/announcements/delete.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { Announcements } from '../../../../../models'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'ecad8040-a276-4e85-bda9-015a708d291e' + } + } +}; + +export default define(meta, async (ps, me) => { + const announcement = await Announcements.findOne(ps.id); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.delete(announcement.id); +}); diff --git a/src/server/api/endpoints/admin/announcements/list.ts b/src/server/api/endpoints/admin/announcements/list.ts new file mode 100644 index 0000000000..f4e622144e --- /dev/null +++ b/src/server/api/endpoints/admin/announcements/list.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { Announcements, AnnouncementReads } from '../../../../../models'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + 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) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + + const announcements = await query.take(ps.limit!).getMany(); + + for (const announcement of announcements) { + (announcement as any).reads = await AnnouncementReads.count({ + announcementId: announcement.id + }); + } + + return announcements; +}); diff --git a/src/server/api/endpoints/admin/announcements/update.ts b/src/server/api/endpoints/admin/announcements/update.ts new file mode 100644 index 0000000000..b65c3a4f93 --- /dev/null +++ b/src/server/api/endpoints/admin/announcements/update.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { Announcements } from '../../../../../models'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc' + } + } +}; + +export default define(meta, async (ps, me) => { + const announcement = await Announcements.findOne(ps.id); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.update(announcement.id, { + updatedAt: new Date(), + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); +}); diff --git a/src/server/api/endpoints/admin/emoji/list-remote.ts b/src/server/api/endpoints/admin/emoji/list-remote.ts new file mode 100644 index 0000000000..0a3e74c333 --- /dev/null +++ b/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '../../../../../models'; +import { toPuny } from '../../../../../misc/convert-host'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '../../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': 'カスタム絵文字を取得します。' + }, + + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + host: { + validator: $.optional.nullable.str, + default: null as any + }, + + 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) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); + + if (ps.host == null) { + q.andWhere(`emoji.host IS NOT NULL`); + } else { + q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); + } + + const emojis = await q + .orderBy('emoji.category', 'ASC') + .orderBy('emoji.name', 'ASC') + .take(ps.limit!) + .getMany(); + + return emojis.map(e => ({ + id: e.id, + name: e.name, + category: e.category, + aliases: e.aliases, + host: e.host, + url: e.url + })); +}); diff --git a/src/server/api/endpoints/admin/emoji/list.ts b/src/server/api/endpoints/admin/emoji/list.ts index d2a5e7df0d..d525a659c0 100644 --- a/src/server/api/endpoints/admin/emoji/list.ts +++ b/src/server/api/endpoints/admin/emoji/list.ts @@ -1,7 +1,8 @@ import $ from 'cafy'; import define from '../../../define'; import { Emojis } from '../../../../../models'; -import { toPunyNullable } from '../../../../../misc/convert-host'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '../../../../../misc/cafy-id'; export const meta = { desc: { @@ -14,23 +15,28 @@ export const meta = { requireModerator: true, params: { - host: { - validator: $.optional.nullable.str, - default: null as any + 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) => { - const emojis = await Emojis.find({ - where: { - host: toPunyNullable(ps.host) - }, - order: { - category: 'ASC', - name: 'ASC' - } - }); + const emojis = await makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere(`emoji.host IS NULL`) + .orderBy('emoji.category', 'ASC') + .orderBy('emoji.name', 'ASC') + .take(ps.limit!) + .getMany(); return emojis.map(e => ({ id: e.id, diff --git a/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/src/server/api/endpoints/admin/queue/deliver-delayed.ts new file mode 100644 index 0000000000..d33837c099 --- /dev/null +++ b/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -0,0 +1,31 @@ +import define from '../../../define'; +import { deliverQueue } from '../../../../../queue'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + } +}; + +export default define(meta, async (ps) => { + const jobs = await deliverQueue.getJobs(['delayed']); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.to).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/src/server/api/endpoints/admin/queue/inbox-delayed.ts new file mode 100644 index 0000000000..643e22f10d --- /dev/null +++ b/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -0,0 +1,31 @@ +import define from '../../../define'; +import { inboxQueue } from '../../../../../queue'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + } +}; + +export default define(meta, async (ps) => { + const jobs = await inboxQueue.getJobs(['delayed']); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.signature.keyId).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/src/server/api/endpoints/admin/server-info.ts b/src/server/api/endpoints/admin/server-info.ts new file mode 100644 index 0000000000..f51040a2c8 --- /dev/null +++ b/src/server/api/endpoints/admin/server-info.ts @@ -0,0 +1,45 @@ +import * as os from 'os'; +import * as si from 'systeminformation'; +import { getConnection } from 'typeorm'; +import define from '../../define'; +import redis from '../../../../db/redis'; + +export const meta = { + requireCredential: false, + + desc: { + }, + + tags: ['meta'], + + params: { + }, +}; + +export default define(meta, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + const netInterface = await si.networkInterfaceDefault(); + + return { + machine: os.hostname(), + os: os.platform(), + node: process.version, + psql: await getConnection().query('SHOW server_version').then(x => x[0].server_version), + redis: redis.server_info.redis_version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + mem: { + total: memStats.total + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + net: { + interface: netInterface + } + }; +}); diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index bc37228d0a..65650f1295 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -13,16 +13,9 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, params: { - announcements: { - validator: $.optional.nullable.arr($.obj()), - desc: { - 'ja-JP': 'お知らせ' - } - }, - disableRegistration: { validator: $.optional.nullable.bool, desc: { @@ -44,13 +37,6 @@ export const meta = { } }, - enableEmojiReaction: { - validator: $.optional.nullable.bool, - desc: { - 'ja-JP': '絵文字リアクションを有効にするか否か' - } - }, - useStarForReactionFallback: { validator: $.optional.nullable.bool, desc: { @@ -347,7 +333,7 @@ export const meta = { } }, - ToSUrl: { + tosUrl: { validator: $.optional.nullable.str, desc: { 'ja-JP': '利用規約のURL' @@ -413,10 +399,6 @@ export const meta = { export default define(meta, async (ps, me) => { const set = {} as Partial<Meta>; - if (ps.announcements) { - set.announcements = ps.announcements; - } - if (typeof ps.disableRegistration === 'boolean') { set.disableRegistration = ps.disableRegistration; } @@ -429,10 +411,6 @@ export default define(meta, async (ps, me) => { set.disableGlobalTimeline = ps.disableGlobalTimeline; } - if (typeof ps.enableEmojiReaction === 'boolean') { - set.enableEmojiReaction = ps.enableEmojiReaction; - } - if (typeof ps.useStarForReactionFallback === 'boolean') { set.useStarForReactionFallback = ps.useStarForReactionFallback; } @@ -601,8 +579,8 @@ export default define(meta, async (ps, me) => { set.swPrivateKey = ps.swPrivateKey; } - if (ps.ToSUrl !== undefined) { - set.ToSUrl = ps.ToSUrl; + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; } if (ps.repositoryUrl !== undefined) { diff --git a/src/server/api/endpoints/announcements.ts b/src/server/api/endpoints/announcements.ts new file mode 100644 index 0000000000..c6050d6092 --- /dev/null +++ b/src/server/api/endpoints/announcements.ts @@ -0,0 +1,42 @@ +import $ from 'cafy'; +import { ID } from '../../../misc/cafy-id'; +import define from '../define'; +import { Announcements, AnnouncementReads } from '../../../models'; +import { makePaginationQuery } from '../common/make-pagination-query'; + +export const meta = { + requireCredential: false, + + params: { + 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(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + + const announcements = await query.take(ps.limit!).getMany(); + + if (user) { + const reads = (await AnnouncementReads.find({ + userId: user.id + })).map(x => x.announcementId); + + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } + } + + return announcements; +}); diff --git a/src/server/api/endpoints/antennas/create.ts b/src/server/api/endpoints/antennas/create.ts new file mode 100644 index 0000000000..0e00eda1a4 --- /dev/null +++ b/src/server/api/endpoints/antennas/create.ts @@ -0,0 +1,92 @@ +import $ from 'cafy'; +import define from '../../define'; +import { genId } from '../../../../misc/gen-id'; +import { Antennas, UserLists } from '../../../../models'; +import { ID } from '../../../../misc/cafy-id'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + }, + + src: { + validator: $.str.or(['home', 'all', 'users', 'list']) + }, + + userListId: { + validator: $.nullable.optional.type(ID), + }, + + keywords: { + validator: $.arr($.arr($.str)) + }, + + users: { + validator: $.arr($.str) + }, + + caseSensitive: { + validator: $.bool + }, + + withReplies: { + validator: $.bool + }, + + withFile: { + validator: $.bool + }, + + notify: { + validator: $.bool + } + }, + + errors: { + noSuchUserList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f' + } + } +}; + +export default define(meta, async (ps, user) => { + let userList; + + if (ps.src === 'list') { + userList = await UserLists.findOne({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } + + const antenna = await Antennas.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + keywords: ps.keywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + return await Antennas.pack(antenna); +}); diff --git a/src/server/api/endpoints/antennas/delete.ts b/src/server/api/endpoints/antennas/delete.ts new file mode 100644 index 0000000000..6bf9165aed --- /dev/null +++ b/src/server/api/endpoints/antennas/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas } from '../../../../models'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + kind: 'write:account', + + params: { + antennaId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df' + } + } +}; + +export default define(meta, async (ps, user) => { + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + await Antennas.delete(antenna.id); +}); diff --git a/src/server/api/endpoints/antennas/list.ts b/src/server/api/endpoints/antennas/list.ts new file mode 100644 index 0000000000..3f9deff32f --- /dev/null +++ b/src/server/api/endpoints/antennas/list.ts @@ -0,0 +1,18 @@ +import define from '../../define'; +import { Antennas } from '../../../../models'; + +export const meta = { + tags: ['antennas', 'account'], + + requireCredential: true, + + kind: 'read:account', +}; + +export default define(meta, async (ps, me) => { + const antennas = await Antennas.find({ + userId: me.id, + }); + + return await Promise.all(antennas.map(x => Antennas.pack(x))); +}); diff --git a/src/server/api/endpoints/antennas/notes.ts b/src/server/api/endpoints/antennas/notes.ts new file mode 100644 index 0000000000..b4c8e7e698 --- /dev/null +++ b/src/server/api/endpoints/antennas/notes.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Antennas, Notes, AntennaNotes } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMuteQuery } from '../../common/generate-mute-query'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['account', 'notes', 'antennas'], + + requireCredential: true, + + kind: 'read:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe' + } + } +}; + +export default define(meta, async (ps, user) => { + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const antennaQuery = AntennaNotes.createQueryBuilder('joining') + .select('joining.noteId') + .where('joining.antennaId = :antennaId', { antennaId: antenna.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.id IN (${ antennaQuery.getQuery() })`) + .leftJoinAndSelect('note.user', 'user') + .setParameters(antennaQuery.getParameters()); + + generateVisibilityQuery(query, user); + generateMuteQuery(query, user); + + const notes = await query + .take(ps.limit!) + .getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/src/server/api/endpoints/antennas/show.ts b/src/server/api/endpoints/antennas/show.ts new file mode 100644 index 0000000000..dd87de1dce --- /dev/null +++ b/src/server/api/endpoints/antennas/show.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas } from '../../../../models'; + +export const meta = { + tags: ['antennas', 'account'], + + requireCredential: true, + + kind: 'read:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'c06569fb-b025-4f23-b22d-1fcd20d2816b' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the antenna + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await Antennas.pack(antenna); +}); diff --git a/src/server/api/endpoints/antennas/update.ts b/src/server/api/endpoints/antennas/update.ts new file mode 100644 index 0000000000..28875d0f08 --- /dev/null +++ b/src/server/api/endpoints/antennas/update.ts @@ -0,0 +1,108 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas, UserLists } from '../../../../models'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + kind: 'write:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100) + }, + + src: { + validator: $.str.or(['home', 'all', 'users', 'list']) + }, + + userListId: { + validator: $.nullable.optional.type(ID), + }, + + keywords: { + validator: $.arr($.arr($.str)) + }, + + users: { + validator: $.arr($.str) + }, + + caseSensitive: { + validator: $.bool + }, + + withReplies: { + validator: $.bool + }, + + withFile: { + validator: $.bool + }, + + notify: { + validator: $.bool + } + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '10c673ac-8852-48eb-aa1f-f5b67f069290' + }, + + noSuchUserList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '1c6b35c9-943e-48c2-81e4-2844989407f7' + } + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the antenna + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + let userList; + + if (ps.src === 'list') { + userList = await UserLists.findOne({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } + + await Antennas.update(antenna.id, { + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + keywords: ps.keywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + return await Antennas.pack(antenna.id); +}); diff --git a/src/server/api/endpoints/clips/create.ts b/src/server/api/endpoints/clips/create.ts new file mode 100644 index 0000000000..a6761c5533 --- /dev/null +++ b/src/server/api/endpoints/clips/create.ts @@ -0,0 +1,29 @@ +import $ from 'cafy'; +import define from '../../define'; +import { genId } from '../../../../misc/gen-id'; +import { Clips } from '../../../../models'; + +export const meta = { + tags: ['clips'], + + requireCredential: true, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + } + }, +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + }); + + return await Clips.pack(clip); +}); diff --git a/src/server/api/endpoints/clips/delete.ts b/src/server/api/endpoints/clips/delete.ts new file mode 100644 index 0000000000..7e185e4652 --- /dev/null +++ b/src/server/api/endpoints/clips/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '../../../../models'; + +export const meta = { + tags: ['clips'], + + requireCredential: true, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754' + } + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.delete(clip.id); +}); diff --git a/src/server/api/endpoints/clips/list.ts b/src/server/api/endpoints/clips/list.ts new file mode 100644 index 0000000000..aa16a18d42 --- /dev/null +++ b/src/server/api/endpoints/clips/list.ts @@ -0,0 +1,18 @@ +import define from '../../define'; +import { Clips } from '../../../../models'; + +export const meta = { + tags: ['clips', 'account'], + + requireCredential: true, + + kind: 'read:account', +}; + +export default define(meta, async (ps, me) => { + const clips = await Clips.find({ + userId: me.id, + }); + + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/src/server/api/endpoints/clips/notes.ts b/src/server/api/endpoints/clips/notes.ts new file mode 100644 index 0000000000..4e76a4d1f3 --- /dev/null +++ b/src/server/api/endpoints/clips/notes.ts @@ -0,0 +1,67 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Clips, Notes } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMuteQuery } from '../../common/generate-mute-query'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: true, + + kind: 'read:account', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such list.', + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00' + } + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const clipQuery = ClipNotes.createQueryBuilder('joining') + .select('joining.noteId') + .where('joining.clipId = :clipId', { clipId: clip.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.id IN (${ clipQuery.getQuery() })`) + .leftJoinAndSelect('note.user', 'user') + .setParameters(clipQuery.getParameters()); + + generateVisibilityQuery(query, user); + generateMuteQuery(query, user); + + const notes = await query + .take(ps.limit!) + .getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/src/server/api/endpoints/clips/show.ts b/src/server/api/endpoints/clips/show.ts new file mode 100644 index 0000000000..0766b3e929 --- /dev/null +++ b/src/server/api/endpoints/clips/show.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '../../../../models'; + +export const meta = { + tags: ['clips', 'account'], + + requireCredential: true, + + kind: 'read:account', + + params: { + clipId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the clip + const clip = await Clips.findOne({ + id: ps.clipId, + userId: me.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + return await Clips.pack(clip); +}); diff --git a/src/server/api/endpoints/clips/update.ts b/src/server/api/endpoints/clips/update.ts new file mode 100644 index 0000000000..d1c31eb8e6 --- /dev/null +++ b/src/server/api/endpoints/clips/update.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '../../../../models'; + +export const meta = { + tags: ['clips'], + + requireCredential: true, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + } + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the clip + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.update(clip.id, { + name: ps.name + }); + + return await Clips.pack(clip.id); +}); diff --git a/src/server/api/endpoints/federation/followers.ts b/src/server/api/endpoints/federation/followers.ts new file mode 100644 index 0000000000..d885daf70e --- /dev/null +++ b/src/server/api/endpoints/federation/followers.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Followings } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + params: { + host: { + validator: $.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', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followeeHost = :host`, { host: ps.host }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/src/server/api/endpoints/federation/following.ts b/src/server/api/endpoints/federation/following.ts new file mode 100644 index 0000000000..1f79817318 --- /dev/null +++ b/src/server/api/endpoints/federation/following.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Followings } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + params: { + host: { + validator: $.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', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followerHost = :host`, { host: ps.host }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/src/server/api/endpoints/federation/instances.ts b/src/server/api/endpoints/federation/instances.ts index bc0eb9a1d7..002cfd4335 100644 --- a/src/server/api/endpoints/federation/instances.ts +++ b/src/server/api/endpoints/federation/instances.ts @@ -9,6 +9,10 @@ export const meta = { requireCredential: false, params: { + host: { + validator: $.optional.nullable.str, + }, + blocked: { validator: $.optional.nullable.bool, }, @@ -17,7 +21,19 @@ export const meta = { validator: $.optional.nullable.bool, }, - markedAsClosed: { + suspended: { + validator: $.optional.nullable.bool, + }, + + federating: { + validator: $.optional.nullable.bool, + }, + + subscribing: { + validator: $.optional.nullable.bool, + }, + + publishing: { validator: $.optional.nullable.bool, }, @@ -41,6 +57,8 @@ export default define(meta, async (ps, me) => { const query = Instances.createQueryBuilder('instance'); switch (ps.sort) { + case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; + case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; case '+users': query.orderBy('instance.usersCount', 'DESC'); break; @@ -78,14 +96,42 @@ export default define(meta, async (ps, me) => { } } - if (typeof ps.markedAsClosed === 'boolean') { - if (ps.markedAsClosed) { - query.andWhere('instance.isMarkedAsClosed = TRUE'); + if (typeof ps.suspended === 'boolean') { + if (ps.suspended) { + query.andWhere('instance.isSuspended = TRUE'); } else { - query.andWhere('instance.isMarkedAsClosed = FALSE'); + query.andWhere('instance.isSuspended = FALSE'); } } + if (typeof ps.federating === 'boolean') { + if (ps.federating) { + query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); + } else { + query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); + } + } + + if (typeof ps.subscribing === 'boolean') { + if (ps.subscribing) { + query.andWhere('instance.followersCount > 0'); + } else { + query.andWhere('instance.followersCount = 0'); + } + } + + if (typeof ps.publishing === 'boolean') { + if (ps.publishing) { + query.andWhere('instance.followingCount > 0'); + } else { + query.andWhere('instance.followingCount = 0'); + } + } + + if (ps.host) { + query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }) + } + const instances = await query.take(ps.limit!).skip(ps.offset).getMany(); return instances; diff --git a/src/server/api/endpoints/federation/users.ts b/src/server/api/endpoints/federation/users.ts new file mode 100644 index 0000000000..f69bbf949c --- /dev/null +++ b/src/server/api/endpoints/federation/users.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { Users } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + params: { + host: { + validator: $.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: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Users.createQueryBuilder('user'), ps.sinceId, ps.untilId) + .andWhere(`user.host = :host`, { host: ps.host }); + + const users = await query + .take(ps.limit!) + .getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index cd00501a2e..f624550d49 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -42,12 +42,12 @@ export const meta = { }, includeTypes: { - validator: $.optional.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'])), + validator: $.optional.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted'])), default: [] as string[] }, excludeTypes: { - validator: $.optional.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'])), + validator: $.optional.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted'])), default: [] as string[] } }, diff --git a/src/server/api/endpoints/i/read-announcement.ts b/src/server/api/endpoints/i/read-announcement.ts new file mode 100644 index 0000000000..c5fbe7d576 --- /dev/null +++ b/src/server/api/endpoints/i/read-announcement.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { genId } from '../../../../misc/gen-id'; +import { AnnouncementReads, Announcements, Users } from '../../../../models'; +import { publishMainStream } from '../../../../services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:account', + + params: { + announcementId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: '184663db-df88-4bc2-8b52-fb85f0681939' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Check if announcement exists + const announcement = await Announcements.findOne(ps.announcementId); + + if (announcement == null) { + throw new ApiError(meta.errors.noSuchAnnouncement); + } + + // Check if already read + const read = await AnnouncementReads.findOne({ + announcementId: ps.announcementId, + userId: user.id + }); + + if (read != null) { + return; + } + + // Create read + await AnnouncementReads.save({ + id: genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: user.id, + }); + + if (!await Users.getHasUnreadAnnouncement(user.id)) { + publishMainStream(user.id, 'readAllAnnouncements'); + } +}); diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index b71c35946e..2c605a6f0b 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -1,11 +1,8 @@ import $ from 'cafy'; -import * as os from 'os'; import config from '../../../config'; import define from '../define'; import { fetchMeta } from '../../../misc/fetch-meta'; -import { Emojis } from '../../../models'; -import { getConnection } from 'typeorm'; -import redis from '../../../db/redis'; +import { Emojis, Users } from '../../../models'; import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../misc/hard-limits'; export const meta = { @@ -83,11 +80,6 @@ export const meta = { optional: false as const, nullable: false as const, description: 'Whether disabled GTL.', }, - enableEmojiReaction: { - type: 'boolean' as const, - optional: false as const, nullable: false as const, - description: 'Whether enabled emoji reaction.', - }, } } }; @@ -119,27 +111,15 @@ export default define(meta, async (ps, me) => { uri: config.url, description: instance.description, langs: instance.langs, - ToSUrl: instance.ToSUrl, + tosUrl: instance.ToSUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, secure: config.https != null, - machine: os.hostname(), - os: os.platform(), - node: process.version, - psql: await getConnection().query('SHOW server_version').then(x => x[0].server_version), - redis: redis.server_info.redis_version, - - cpu: { - model: os.cpus()[0].model, - cores: os.cpus().length - }, - announcements: instance.announcements || [], disableRegistration: instance.disableRegistration, disableLocalTimeline: instance.disableLocalTimeline, disableGlobalTimeline: instance.disableGlobalTimeline, - enableEmojiReaction: instance.enableEmojiReaction, driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, cacheRemoteFiles: instance.cacheRemoteFiles, @@ -159,6 +139,7 @@ export default define(meta, async (ps, me) => { category: e.category, url: e.url, })), + requireSetup: (await Users.count({})) === 0, enableEmail: instance.enableEmail, enableTwitterIntegration: instance.enableTwitterIntegration, @@ -183,7 +164,7 @@ export default define(meta, async (ps, me) => { }; } - if (me && (me.isAdmin || me.isModerator)) { + if (me && me.isAdmin) { response.useStarForReactionFallback = instance.useStarForReactionFallback; response.pinnedUsers = instance.pinnedUsers; response.hiddenTags = instance.hiddenTags; diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index 810ad51b67..73db73ed97 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -113,23 +113,6 @@ export const meta = { } }, - geo: { - validator: $.optional.nullable.obj({ - coordinates: $.arr().length(2) - .item(0, $.num.range(-180, 180)) - .item(1, $.num.range(-90, 90)), - altitude: $.nullable.num, - accuracy: $.nullable.num, - altitudeAccuracy: $.nullable.num, - heading: $.nullable.num.range(0, 360), - speed: $.nullable.num - }).strict(), - desc: { - 'ja-JP': '位置情報' - }, - ref: 'geo' - }, - fileIds: { validator: $.optional.arr($.type(ID)).unique().range(1, 4), desc: { @@ -308,7 +291,6 @@ export default define(meta, async (ps, user, app) => { apMentions: ps.noExtractMentions ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined, - geo: ps.geo }); return { diff --git a/src/server/api/endpoints/notes/featured.ts b/src/server/api/endpoints/notes/featured.ts index 0a1d8668b0..a499afabf0 100644 --- a/src/server/api/endpoints/notes/featured.ts +++ b/src/server/api/endpoints/notes/featured.ts @@ -15,12 +15,17 @@ export const meta = { params: { limit: { - validator: $.optional.num.range(1, 30), + validator: $.optional.num.range(1, 100), default: 10, desc: { 'ja-JP': '最大数' } - } + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, }, res: { @@ -35,6 +40,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { + const max = 30; const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで const query = Notes.createQueryBuilder('note') @@ -46,7 +52,14 @@ export default define(meta, async (ps, user) => { if (user) generateMuteQuery(query, user); - const notes = await query.orderBy('note.score', 'DESC').take(ps.limit!).getMany(); + let notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); + + notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + notes = notes.slice(ps.offset, ps.offset + ps.limit); return await Notes.packMany(notes, user); }); diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts index 5557b469e4..efc08d0d4a 100644 --- a/src/server/api/endpoints/notes/search.ts +++ b/src/server/api/endpoints/notes/search.ts @@ -1,11 +1,13 @@ import $ from 'cafy'; import es from '../../../../db/elasticsearch'; import define from '../../define'; -import { ApiError } from '../../error'; import { Notes } from '../../../../models'; import { In } from 'typeorm'; import { ID } from '../../../../misc/cafy-id'; import config from '../../../../config'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMuteQuery } from '../../common/generate-mute-query'; export const meta = { desc: { @@ -22,16 +24,19 @@ export const meta = { validator: $.str }, + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + limit: { validator: $.optional.num.range(1, 100), default: 10 }, - offset: { - validator: $.optional.num.min(0), - default: 0 - }, - host: { validator: $.optional.nullable.str, default: undefined @@ -54,74 +59,80 @@ export const meta = { }, errors: { - searchingNotAvailable: { - message: 'Searching not available.', - code: 'SEARCHING_NOT_AVAILABLE', - id: '7ee9c119-16a1-479f-a6fd-6fab00ed946f' - } } }; export default define(meta, async (ps, me) => { - if (es == null) throw new ApiError(meta.errors.searchingNotAvailable); + if (es == null) { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) + .leftJoinAndSelect('note.user', 'user'); - const userQuery = ps.userId != null ? [{ - term: { - userId: ps.userId - } - }] : []; + generateVisibilityQuery(query, me); + if (me) generateMuteQuery(query, me); - const hostQuery = ps.userId == null ? - ps.host === null ? [{ - bool: { - must_not: { - exists: { - field: 'userHost' - } - } - } - }] : ps.host !== undefined ? [{ + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, me); + } else { + const userQuery = ps.userId != null ? [{ term: { - userHost: ps.host + userId: ps.userId } - }] : [] - : []; + }] : []; - const result = await es.search({ - index: config.elasticsearch.index || 'misskey_note', - body: { - size: ps.limit!, - from: ps.offset, - query: { + const hostQuery = ps.userId == null ? + ps.host === null ? [{ bool: { - must: [{ - simple_query_string: { - fields: ['text'], - query: ps.query.toLowerCase(), - default_operator: 'and' - }, - }, ...hostQuery, ...userQuery] + must_not: { + exists: { + field: 'userHost' + } + } } - }, - sort: [{ - _doc: 'desc' - }] - } - }); + }] : ps.host !== undefined ? [{ + term: { + userHost: ps.host + } + }] : [] + : []; + + const result = await es.search({ + index: config.elasticsearch.index || 'misskey_note', + body: { + size: ps.limit!, + from: ps.offset, + query: { + bool: { + must: [{ + simple_query_string: { + fields: ['text'], + query: ps.query.toLowerCase(), + default_operator: 'and' + }, + }, ...hostQuery, ...userQuery] + } + }, + sort: [{ + _doc: 'desc' + }] + } + }); - const hits = result.body.hits.hits.map((hit: any) => hit._id); + const hits = result.body.hits.hits.map((hit: any) => hit._id); - if (hits.length === 0) return []; + if (hits.length === 0) return []; - // Fetch found notes - const notes = await Notes.find({ - where: { - id: In(hits) - }, - order: { - id: -1 - } - }); + // Fetch found notes + const notes = await Notes.find({ + where: { + id: In(hits) + }, + order: { + id: -1 + } + }); - return await Notes.packMany(notes, me); + return await Notes.packMany(notes, me); + } }); diff --git a/src/server/api/endpoints/users/search-by-username-and-host.ts b/src/server/api/endpoints/users/search-by-username-and-host.ts new file mode 100644 index 0000000000..8544731dfd --- /dev/null +++ b/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -0,0 +1,101 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users } from '../../../../models'; +import { User } from '../../../../models/entities/user'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーを検索します。' + }, + + tags: ['users'], + + requireCredential: false, + + params: { + username: { + validator: $.optional.nullable.str, + desc: { + 'ja-JP': 'クエリ' + } + }, + + host: { + validator: $.optional.nullable.str, + desc: { + 'ja-JP': 'クエリ' + } + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + desc: { + 'ja-JP': 'オフセット' + } + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + desc: { + 'ja-JP': '取得する数' + } + }, + + detail: { + validator: $.optional.bool, + default: true, + desc: { + 'ja-JP': '詳細なユーザー情報を含めるか否か' + } + }, + }, + + 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) => { + 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() + '%' }) + } + + const users = await q.take(ps.limit!).skip(ps.offset).getMany(); + + return await Users.packMany(users, me, { detail: ps.detail }); + } else { + let users = await Users.createQueryBuilder('user') + .where('user.host IS NULL') + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }) + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + + if (users.length < ps.limit!) { + const otherUsers = await Users.createQueryBuilder('user') + .where('user.host IS NOT NULL') + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }) + .take(ps.limit! - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + + return await Users.packMany(users, me, { detail: ps.detail }); + } +}); diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts index af1aefda84..79ee74389c 100644 --- a/src/server/api/private/signup.ts +++ b/src/server/api/private/signup.ts @@ -1,19 +1,8 @@ import * as Koa from 'koa'; -import * as bcrypt from 'bcryptjs'; -import { generateKeyPair } from 'crypto'; -import generateUserToken from '../common/generate-native-user-token'; -import config from '../../../config'; import { fetchMeta } from '../../../misc/fetch-meta'; import * as recaptcha from 'recaptcha-promise'; -import { Users, Signins, RegistrationTickets, UsedUsernames } from '../../../models'; -import { genId } from '../../../misc/gen-id'; -import { usersChart } from '../../../services/chart'; -import { User } from '../../../models/entities/user'; -import { UserKeypair } from '../../../models/entities/user-keypair'; -import { toPunyNullable } from '../../../misc/convert-host'; -import { UserProfile } from '../../../models/entities/user-profile'; -import { getConnection } from 'typeorm'; -import { UsedUsername } from '../../../models/entities/used-username'; +import { Users, RegistrationTickets } from '../../../models'; +import { signup } from '../common/signup'; export default async (ctx: Koa.Context) => { const body = ctx.request.body; @@ -31,7 +20,6 @@ export default async (ctx: Koa.Context) => { if (!success) { ctx.throw(400, 'recaptcha-failed'); - return; } } @@ -58,114 +46,18 @@ export default async (ctx: Koa.Context) => { RegistrationTickets.delete(ticket.id); } - // Validate username - if (!Users.validateLocalUsername.ok(username)) { - ctx.status = 400; - return; - } - - // Validate password - if (!Users.validatePassword.ok(password)) { - ctx.status = 400; - return; - } - - const usersCount = await Users.count({}); - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); - - // Generate secret - const secret = generateUserToken(); - - // Check username duplication - if (await Users.findOne({ usernameLower: username.toLowerCase(), host: null })) { - ctx.status = 400; - return; - } - - // Check deleted username duplication - if (await UsedUsernames.findOne({ username: username.toLowerCase() })) { - ctx.status = 400; - return; - } - - const keyPair = await new Promise<string[]>((res, rej) => - generateKeyPair('rsa', { - modulusLength: 4096, - publicKeyEncoding: { - type: 'spki', - format: 'pem' - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - cipher: undefined, - passphrase: undefined - } - } as any, (err, publicKey, privateKey) => - err ? rej(err) : res([publicKey, privateKey]) - )); - - let account!: User; + try { + const { account, secret } = await signup(username, password, host); - // Start transaction - await getConnection().transaction(async transactionalEntityManager => { - const exist = await transactionalEntityManager.findOne(User, { - usernameLower: username.toLowerCase(), - host: null + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true }); - if (exist) throw new Error(' the username is already used'); - - account = await transactionalEntityManager.save(new User({ - id: genId(), - createdAt: new Date(), - username: username, - usernameLower: username.toLowerCase(), - host: toPunyNullable(host), - token: secret, - isAdmin: config.autoAdmin && usersCount === 0, - })); - - await transactionalEntityManager.save(new UserKeypair({ - publicKey: keyPair[0], - privateKey: keyPair[1], - userId: account.id - })); - - await transactionalEntityManager.save(new UserProfile({ - userId: account.id, - autoAcceptFollowed: true, - autoWatch: false, - password: hash, - })); - - await transactionalEntityManager.save(new UsedUsername({ - createdAt: new Date(), - username: username.toLowerCase(), - })); - }); + (res as any).token = secret; - usersChart.update(account, true); - - // Append signin history - await Signins.save({ - id: genId(), - createdAt: new Date(), - userId: account.id, - ip: ctx.ip, - headers: ctx.headers, - success: true - }); - - const res = await Users.pack(account, account, { - detail: true, - includeSecrets: true - }); - - (res as any).token = secret; - - ctx.body = res; + ctx.body = res; + } catch (e) { + ctx.throw(400, e); + } }; diff --git a/src/server/api/stream/channels/antenna.ts b/src/server/api/stream/channels/antenna.ts new file mode 100644 index 0000000000..714edb502d --- /dev/null +++ b/src/server/api/stream/channels/antenna.ts @@ -0,0 +1,41 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes } from '../../../../models'; +import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; + +export default class extends Channel { + public readonly chName = 'antenna'; + public static shouldShare = false; + public static requireCredential = false; + private antennaId: string; + + @autobind + public async init(params: any) { + this.antennaId = params.antennaId as string; + + // Subscribe stream + this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); + } + + @autobind + private async onEvent(data: any) { + const { type, body } = data; + + if (type === 'note') { + const note = await Notes.pack(body.id, this.user, { detail: true }); + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (shouldMuteThisNote(note, this.muting)) return; + + this.send('note', note); + } else { + this.send(type, body); + } + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); + } +} diff --git a/src/server/api/stream/channels/ap-log.ts b/src/server/api/stream/channels/ap-log.ts deleted file mode 100644 index 867fd3670b..0000000000 --- a/src/server/api/stream/channels/ap-log.ts +++ /dev/null @@ -1,25 +0,0 @@ -import autobind from 'autobind-decorator'; -import Channel from '../channel'; - -export default class extends Channel { - public readonly chName = 'apLog'; - public static shouldShare = true; - public static requireCredential = false; - - @autobind - public async init(params: any) { - // Subscribe events - this.subscriber.on('apLog', this.onLog); - } - - @autobind - private async onLog(log: any) { - this.send('log', log); - } - - @autobind - public dispose() { - // Unsubscribe events - this.subscriber.off('apLog', this.onLog); - } -} diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts index b9feb70258..e32f4111c2 100644 --- a/src/server/api/stream/channels/hybrid-timeline.ts +++ b/src/server/api/stream/channels/hybrid-timeline.ts @@ -50,7 +50,7 @@ export default class extends Channel { detail: true }); } - } + } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (shouldMuteThisNote(note, this.muting)) return; diff --git a/src/server/api/stream/channels/index.ts b/src/server/api/stream/channels/index.ts index 4527fb1e46..6efad078c6 100644 --- a/src/server/api/stream/channels/index.ts +++ b/src/server/api/stream/channels/index.ts @@ -3,15 +3,14 @@ import homeTimeline from './home-timeline'; import localTimeline from './local-timeline'; import hybridTimeline from './hybrid-timeline'; import globalTimeline from './global-timeline'; -import notesStats from './notes-stats'; import serverStats from './server-stats'; import queueStats from './queue-stats'; import userList from './user-list'; +import antenna from './antenna'; import messaging from './messaging'; import messagingIndex from './messaging-index'; import drive from './drive'; import hashtag from './hashtag'; -import apLog from './ap-log'; import admin from './admin'; import gamesReversi from './games/reversi'; import gamesReversiGame from './games/reversi-game'; @@ -22,15 +21,14 @@ export default { localTimeline, hybridTimeline, globalTimeline, - notesStats, serverStats, queueStats, userList, + antenna, messaging, messagingIndex, drive, hashtag, - apLog, admin, gamesReversi, gamesReversiGame diff --git a/src/server/api/stream/channels/notes-stats.ts b/src/server/api/stream/channels/notes-stats.ts deleted file mode 100644 index 0c6b84d6cf..0000000000 --- a/src/server/api/stream/channels/notes-stats.ts +++ /dev/null @@ -1,38 +0,0 @@ -import autobind from 'autobind-decorator'; -import Xev from 'xev'; -import Channel from '../channel'; - -const ev = new Xev(); - -export default class extends Channel { - public readonly chName = 'notesStats'; - public static shouldShare = true; - public static requireCredential = false; - - @autobind - public async init(params: any) { - ev.addListener('notesStats', this.onStats); - } - - @autobind - private onStats(stats: any) { - this.send('stats', stats); - } - - @autobind - public onMessage(type: string, body: any) { - switch (type) { - case 'requestLog': - ev.once(`notesStatsLog:${body.id}`, statsLog => { - this.send('statsLog', statsLog); - }); - ev.emit('requestNotesStatsLog', body.id); - break; - } - } - - @autobind - public dispose() { - ev.removeListener('notesStats', this.onStats); - } -} diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index f73f3229d5..6ec644a024 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -9,6 +9,7 @@ import { EventEmitter } from 'events'; import { User } from '../../../models/entities/user'; import { App } from '../../../models/entities/app'; import { Users, Followings, Mutings } from '../../../models'; +import { ApiError } from '../error'; /** * Main stream connection @@ -83,8 +84,16 @@ export default class Connection { // 呼び出し call(endpoint, user, this.app, payload.data).then(res => { this.sendMessageToWs(`api:${payload.id}`, { res }); - }).catch(e => { - this.sendMessageToWs(`api:${payload.id}`, { e }); + }).catch((e: ApiError) => { + this.sendMessageToWs(`api:${payload.id}`, { + error: { + message: e.message, + code: e.code, + id: e.id, + kind: e.kind, + ...(e.info ? { info: e.info } : {}) + } + }); }); } @@ -111,7 +120,7 @@ export default class Connection { this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); } - if (payload.read && this.user) { + if (this.user) { readNote(this.user.id, payload.id); } } diff --git a/src/server/nodeinfo.ts b/src/server/nodeinfo.ts index 211fa2a73e..2ff924e68d 100644 --- a/src/server/nodeinfo.ts +++ b/src/server/nodeinfo.ts @@ -59,10 +59,9 @@ const nodeinfo2 = async () => { email: meta.maintainerEmail }, langs: meta.langs, - ToSUrl: meta.ToSUrl, + tosUrl: meta.ToSUrl, repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, - announcements: meta.announcements, disableRegistration: meta.disableRegistration, disableLocalTimeline: meta.disableLocalTimeline, disableGlobalTimeline: meta.disableGlobalTimeline, diff --git a/src/server/web/docs.ts b/src/server/web/docs.ts index 558e811466..c6c5fd1e2f 100644 --- a/src/server/web/docs.ts +++ b/src/server/web/docs.ts @@ -12,7 +12,6 @@ import * as send from 'koa-send'; import * as glob from 'glob'; import config from '../../config'; import { licenseHtml } from '../../misc/license'; -import { copyright } from '../../const.json'; import * as locales from '../../../locales'; import * as nestedProperty from 'nested-property'; @@ -48,7 +47,7 @@ async function genVars(lang: string): Promise<{ [key: string]: any }> { vars['config'] = config; - vars['copyright'] = copyright; + vars['copyright'] = '(c) Misskey'; vars['license'] = licenseHtml; diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 06c7274f5a..57bcb855a1 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -31,6 +31,7 @@ const app = new Koa(); app.use(views(__dirname + '/views', { extension: 'pug', options: { + version: config.version, config } })); diff --git a/src/server/web/views/base.pug b/src/server/web/views/base.pug index 97c7a87e1b..43b82a5f05 100644 --- a/src/server/web/views/base.pug +++ b/src/server/web/views/base.pug @@ -10,7 +10,7 @@ html meta(charset='utf-8') meta(name='application-name' content='Misskey') meta(name='referrer' content='origin') - meta(name='theme-color' content='#105779') + meta(name='theme-color' content='#86b300') meta(property='og:site_name' content= instanceName || 'Misskey') meta(name='viewport' content='width=device-width, initial-scale=1') link(rel='icon' href= icon || '/favicon.ico') @@ -30,12 +30,23 @@ html meta(property='og:image' content=img) style - include ./../../../../built/client/assets/init.css - script - include ./../../../../built/client/assets/boot.js - - script - include ./../../../../built/client/assets/safe.js + include ./../../../../built/client/assets/style.css + script(src=`/assets/app.${version}.js` async defer) + script. + const theme = localStorage.getItem('theme'); + if (theme) { + for (const [k, v] of Object.entries(JSON.parse(theme))) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + if (k === 'accent') { + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', v); + break; + } + } + } + } + } body noscript: p |