diff options
Diffstat (limited to 'packages/backend/src/server/api/endpoints/admin')
55 files changed, 3635 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts new file mode 100644 index 0000000000..403eb24191 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -0,0 +1,134 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { AbuseUserReports } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + state: { + validator: $.optional.nullable.str, + default: null, + }, + + reporterOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + + targetUserOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + }, + + 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, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'date-time', + }, + comment: { + type: 'string' as const, + nullable: false as const, optional: false as const, + }, + resolved: { + type: 'boolean' as const, + nullable: false as const, optional: false as const, + example: false + }, + reporterId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + targetUserId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + assigneeId: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'id', + }, + reporter: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + }, + targetUser: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + }, + assignee: { + type: 'object' as const, + nullable: true as const, optional: true as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + switch (ps.state) { + case 'resolved': query.andWhere('report.resolved = TRUE'); break; + case 'unresolved': query.andWhere('report.resolved = FALSE'); break; + } + + switch (ps.reporterOrigin) { + case 'local': query.andWhere('report.reporterHost IS NULL'); break; + case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; + } + + switch (ps.targetUserOrigin) { + case 'local': query.andWhere('report.targetUserHost IS NULL'); break; + case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; + } + + const reports = await query.take(ps.limit!).getMany(); + + return await AbuseUserReports.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts new file mode 100644 index 0000000000..fa15e84f77 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -0,0 +1,51 @@ +import define from '../../../define'; +import { Users } from '@/models/index'; +import { signup } from '../../../common/signup'; + +export const meta = { + tags: ['admin'], + + params: { + username: { + validator: Users.validateLocalUsername, + }, + + password: { + validator: Users.validatePassword, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps, _me) => { + const me = _me ? await Users.findOneOrFail(_me.id) : null; + const noUsers = (await Users.count({ + host: null, + })) === 0; + if (!noUsers && !me?.isAdmin) throw new Error('access denied'); + + const { account, secret } = await signup({ + username: ps.username, + password: ps.password, + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true + }); + + (res as any).token = secret; + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts new file mode 100644 index 0000000000..4e8a559805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Users } from '@/models/index'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } + + if (Users.isLocalUser(user)) { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false + }); + } else { + createDeleteAccountJob(user, { + soft: true // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + }); + } + + await Users.update(user.id, { + isDeleted: true, + }); + + if (Users.isLocalUser(user)) { + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts new file mode 100644 index 0000000000..27c7b5d318 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Ads } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + url: { + validator: $.str.min(1) + }, + memo: { + validator: $.str + }, + place: { + validator: $.str + }, + priority: { + validator: $.str + }, + ratio: { + validator: $.num.int().min(0) + }, + expiresAt: { + validator: $.num.int() + }, + imageUrl: { + validator: $.str.min(1) + } + }, +}; + +export default define(meta, async (ps) => { + await Ads.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + url: ps.url, + imageUrl: ps.imageUrl, + priority: ps.priority, + ratio: ps.ratio, + place: ps.place, + memo: ps.memo, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts new file mode 100644 index 0000000000..91934e1aab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Ads } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchAd: { + message: 'No such ad.', + code: 'NO_SUCH_AD', + id: 'ccac9863-3a03-416e-b899-8a64041118b1' + } + } +}; + +export default define(meta, async (ps, me) => { + const ad = await Ads.findOne(ps.id); + + if (ad == null) throw new ApiError(meta.errors.noSuchAd); + + await Ads.delete(ad.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts new file mode 100644 index 0000000000..000aaaba9d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Ads } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + 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(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId) + .andWhere('ad.expiresAt > :now', { now: new Date() }); + + const ads = await query.take(ps.limit!).getMany(); + + return ads; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts new file mode 100644 index 0000000000..36c87895c2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -0,0 +1,63 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Ads } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + memo: { + validator: $.str + }, + url: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.str.min(1) + }, + place: { + validator: $.str + }, + priority: { + validator: $.str + }, + ratio: { + validator: $.num.int().min(0) + }, + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchAd: { + message: 'No such ad.', + code: 'NO_SUCH_AD', + id: 'b7aa1727-1354-47bc-a182-3a9c3973d300' + } + } +}; + +export default define(meta, async (ps, me) => { + const ad = await Ads.findOne(ps.id); + + if (ad == null) throw new ApiError(meta.errors.noSuchAd); + + await Ads.update(ad.id, { + url: ps.url, + place: ps.place, + priority: ps.priority, + ratio: ps.ratio, + memo: ps.memo, + imageUrl: ps.imageUrl, + expiresAt: new Date(ps.expiresAt), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts new file mode 100644 index 0000000000..f1c07745f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Announcements } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + }, + + res: { + 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', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + } + } + } +}; + +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/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts new file mode 100644 index 0000000000..7dbc05b4c9 --- /dev/null +++ b/packages/backend/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/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + 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/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts new file mode 100644 index 0000000000..4039bcd88f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Announcements, AnnouncementReads } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + 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, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + reads: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +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/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts new file mode 100644 index 0000000000..343f37d626 --- /dev/null +++ b/packages/backend/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/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + 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/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts new file mode 100644 index 0000000000..988ab29558 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userId: ps.userId + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-logs.ts b/packages/backend/src/server/api/endpoints/admin/delete-logs.ts new file mode 100644 index 0000000000..9d37ceb434 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-logs.ts @@ -0,0 +1,13 @@ +import define from '../../define'; +import { Logs } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps) => { + await Logs.clear(); // TRUNCATE +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts new file mode 100644 index 0000000000..76a6acff59 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -0,0 +1,13 @@ +import define from '../../../define'; +import { createCleanRemoteFilesJob } from '@/queue/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + createCleanRemoteFilesJob(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts new file mode 100644 index 0000000000..8497478da9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -0,0 +1,21 @@ +import { IsNull } from 'typeorm'; +import define from '../../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userId: IsNull() + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts new file mode 100644 index 0000000000..fe1c799805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: false as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + type: { + validator: $.optional.nullable.str.match(/^[a-zA-Z0-9\/\-*]+$/) + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + }, + + hostname: { + validator: $.optional.nullable.str, + default: null + }, + }, + + 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: 'DriveFile' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); + + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } + + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts new file mode 100644 index 0000000000..270b89c4fa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -0,0 +1,180 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + fileId: { + validator: $.optional.type(ID), + }, + + url: { + validator: $.optional.str, + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'caf3ca38-c6e5-472e-a30c-b05377dcc240' + } + }, + + res: { + 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', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + userHost: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + md5: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'md5', + example: '15eca7fba0480996e2245f5185bf39f2' + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'lenna.jpg' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'image/jpeg' + }, + size: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 51469 + }, + comment: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + blurhash: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + properties: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + width: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 1280 + }, + height: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 720 + }, + avgColor: { + type: 'string' as const, + optional: true as const, nullable: false as const, + example: 'rgb(40,65,87)' + } + } + }, + storedInternal: { + type: 'boolean' as const, + optional: false as const, nullable: true as const, + example: true + }, + url: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + thumbnailUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + webpublicUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + accessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + thumbnailAccessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + webpublicAccessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + src: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + folderId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + isSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + isLink: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, me) => { + const file = ps.fileId ? await DriveFiles.findOne(ps.fileId) : await DriveFiles.findOne({ + where: [{ + url: ps.url + }, { + thumbnailUrl: ps.url + }, { + webpublicUrl: ps.url + }] + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + return file; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts new file mode 100644 index 0000000000..1af81fe46d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis, DriveFiles } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { ApiError } from '../../../error'; +import { ID } from '@/misc/cafy-id'; +import rndstr from 'rndstr'; +import { publishBroadcastStream } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + fileId: { + validator: $.type(ID) + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'MO_SUCH_FILE', + id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf' + } + } +}; + +export default define(meta, async (ps, me) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + + const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; + + const emoji = await Emojis.save({ + id: genId(), + updatedAt: new Date(), + name: name, + category: null, + host: null, + aliases: [], + url: file.url, + type: file.type, + }); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(emoji.id) + }); + + insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id + }); + + return { + id: emoji.id + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts new file mode 100644 index 0000000000..4c8ab99f7c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { getConnection } from 'typeorm'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; +import { ID } from '@/misc/cafy-id'; +import uploadFromUrl from '@/services/drive/upload-from-url'; +import { publishBroadcastStream } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + emojiId: { + validator: $.type(ID) + }, + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: 'e2785b66-dca3-4087-9cac-b93c541cc425' + } + }, + + res: { + 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', + } + } + } +}; + +export default define(meta, async (ps, me) => { + const emoji = await Emojis.findOne(ps.emojiId); + + if (emoji == null) { + throw new ApiError(meta.errors.noSuchEmoji); + } + + let driveFile: DriveFile; + + try { + // Create file + driveFile = await uploadFromUrl(emoji.url, null, null, null, false, true); + } catch (e) { + throw new ApiError(); + } + + const copied = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emoji.name, + host: null, + aliases: [], + url: driveFile.url, + type: driveFile.type, + fileId: driveFile.id, + }).then(x => Emojis.findOneOrFail(x.identifiers[0])); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(copied.id) + }); + + return { + id: copied.id + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts new file mode 100644 index 0000000000..3c8ca22170 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -0,0 +1,99 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + query: { + validator: $.optional.nullable.str, + default: null + }, + + host: { + validator: $.optional.nullable.str, + default: null + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + 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, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +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) }); + } + + if (ps.query) { + q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + } + + const emojis = await q + .orderBy('emoji.id', 'DESC') + .take(ps.limit!) + .getMany(); + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts new file mode 100644 index 0000000000..cb1e79e0fe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -0,0 +1,98 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; +import { Emoji } from '@/models/entities/emoji'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + query: { + validator: $.optional.nullable.str, + default: null + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + 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, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere(`emoji.host IS NULL`); + + let emojis: Emoji[]; + + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //const emojis = await q.take(ps.limit!).getMany(); + + emojis = await q.getMany(); + + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + + emojis.splice(ps.limit! + 1); + } else { + emojis = await q.take(ps.limit!).getMany(); + } + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts new file mode 100644 index 0000000000..259950e362 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts @@ -0,0 +1,42 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Emojis } from '@/models/index'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: 'be83669b-773a-44b7-b1f8-e5e5170ac3c2' + } + } +}; + +export default define(meta, async (ps, me) => { + const emoji = await Emojis.findOne(ps.id); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.delete(emoji.id); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'removeEmoji', { + emoji: emoji + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts new file mode 100644 index 0000000000..3fd547d7e5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Emojis } from '@/models/index'; +import { getConnection } from 'typeorm'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + + name: { + validator: $.str + }, + + category: { + validator: $.optional.nullable.str + }, + + aliases: { + validator: $.arr($.str) + } + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8' + } + } +}; + +export default define(meta, async (ps) => { + const emoji = await Emojis.findOne(ps.id); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.update(emoji.id, { + updatedAt: new Date(), + name: ps.name, + category: ps.category, + aliases: ps.aliases, + }); + + await getConnection().queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts new file mode 100644 index 0000000000..82540c5447 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -0,0 +1,27 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userHost: ps.host + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts new file mode 100644 index 0000000000..65a6947ba0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOne({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + fetchInstanceMetadata(instance, true); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts new file mode 100644 index 0000000000..7935eaa631 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import define from '../../../define'; +import deleteFollowing from '@/services/following/delete'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, me) => { + const followings = await Followings.find({ + followerHost: ps.host + }); + + const pairs = await Promise.all(followings.map(f => Promise.all([ + Users.findOneOrFail(f.followerId), + Users.findOneOrFail(f.followeeId) + ]))); + + for (const pair of pairs) { + deleteFollowing(pair[0], pair[1]); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts new file mode 100644 index 0000000000..34eab27c78 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + }, + + isSuspended: { + validator: $.bool + }, + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOne({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + Instances.update({ host: toPuny(ps.host) }, { + isSuspended: ps.isSuspended + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts new file mode 100644 index 0000000000..f2b06d0ef2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -0,0 +1,26 @@ +import define from '../../define'; +import { getConnection } from 'typeorm'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin'], + + params: { + }, +}; + +export default define(meta, async () => { + const stats = await + getConnection().query(`SELECT * FROM pg_indexes;`) + .then(recs => { + const res = [] as { tablename: string; indexname: string; }[]; + for (const rec of recs) { + res.push(rec); + } + return res; + }); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts new file mode 100644 index 0000000000..bce813232b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -0,0 +1,45 @@ +import define from '../../define'; +import { getConnection } from 'typeorm'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + example: { + migrations: { + count: 66, + size: 32768 + }, + } + } +}; + +export default define(meta, async () => { + const sizes = await + getConnection().query(` + SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND C.relkind <> 'i' + AND nspname !~ '^pg_toast';`) + .then(recs => { + const res = {} as Record<string, { count: number; size: number; }>; + for (const rec of recs) { + res[rec.table] = { + count: parseInt(rec.count, 10), + size: parseInt(rec.size, 10), + }; + } + return res; + }); + + return sizes; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts new file mode 100644 index 0000000000..2c69eec535 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -0,0 +1,44 @@ +import rndstr from 'rndstr'; +import define from '../../define'; +import { RegistrationTickets } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + code: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: '2ERUA5VR', + maxLength: 8, + minLength: 8 + } + } + } +}; + +export default define(meta, async () => { + const code = rndstr({ + length: 8, + chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) + }); + + await RegistrationTickets.insert({ + id: genId(), + createdAt: new Date(), + code, + }); + + return { + code, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts new file mode 100644 index 0000000000..2b87fc217f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot mark as moderator if admin user'); + } + + await Users.update(user.id, { + isModerator: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts new file mode 100644 index 0000000000..cbb0625224 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -0,0 +1,29 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isModerator: false + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts new file mode 100644 index 0000000000..3bdaaad4d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { PromoNotes } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + noteId: { + validator: $.type(ID), + }, + + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ee449fbe-af2a-453b-9cae-cf2fe7c895fc' + }, + + alreadyPromoted: { + message: 'The note has already promoted.', + code: 'ALREADY_PROMOTED', + id: 'ae427aa2-7a41-484f-a18c-2c1104051604' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await PromoNotes.findOne(note.id); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyPromoted); + } + + await PromoNotes.insert({ + noteId: note.id, + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + userId: note.userId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts new file mode 100644 index 0000000000..fedb7065ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -0,0 +1,18 @@ +import define from '../../../define'; +import { destroy } from '@/queue/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {} +}; + +export default define(meta, async (ps, me) => { + destroy(); + + insertModerationLog(me, 'clearQueue'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts new file mode 100644 index 0000000000..cd7b640983 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -0,0 +1,55 @@ +import { deliverQueue } from '@/queue/queues'; +import { URL } from 'url'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + anyOf: [ + { + type: 'string' as const, + }, + { + type: 'number' as const, + } + ] + } + }, + example: [[ + 'example.com', + 12 + ]] + } +}; + +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/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts new file mode 100644 index 0000000000..1925906c28 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -0,0 +1,55 @@ +import { URL } from 'url'; +import define from '../../../define'; +import { inboxQueue } from '@/queue/queues'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + anyOf: [ + { + type: 'string' as const, + }, + { + type: 'number' as const, + } + ] + } + }, + example: [[ + 'example.com', + 12 + ]] + } +}; + +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/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..c426e5f39b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,81 @@ +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues'; +import $ from 'cafy'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + domain: { + validator: $.str.or(['deliver', 'inbox', 'db', 'objectStorage']), + }, + + state: { + validator: $.str.or(['active', 'waiting', 'delayed']), + }, + + limit: { + validator: $.optional.num, + default: 50 + }, + }, + + 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, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + data: { + type: 'object' as const, + optional: false as const, nullable: false as const + }, + attempts: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + maxAttempts: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + timestamp: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps) => { + const queue = + ps.domain === 'deliver' ? deliverQueue : + ps.domain === 'inbox' ? inboxQueue : + ps.domain === 'db' ? dbQueue : + ps.domain === 'objectStorage' ? objectStorageQueue : + null as never; + + const jobs = await queue.getJobs([ps.state], 0, ps.limit!); + + return jobs.map(job => { + const data = job.data; + delete data.content; + delete data.user; + return { + id: job.id, + data, + attempts: job.attemptsMade, + maxAttempts: job.opts ? job.opts.attempts : 0, + timestamp: job.timestamp, + }; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts new file mode 100644 index 0000000000..38f18459dd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -0,0 +1,44 @@ +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + deliver: { + ref: 'QueueCount' + }, + inbox: { + ref: 'QueueCount' + }, + db: { + ref: 'QueueCount' + }, + objectStorage: { + ref: 'QueueCount' + } + } + } +}; + +export default define(meta, async (ps) => { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + const dbJobCounts = await dbQueue.getJobCounts(); + const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); + + return { + deliver: deliverJobCounts, + inbox: inboxJobCounts, + db: dbJobCounts, + objectStorage: objectStorageJobCounts, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts new file mode 100644 index 0000000000..567035fd3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -0,0 +1,63 @@ +import { URL } from 'url'; +import $ from 'cafy'; +import define from '../../../define'; +import { addRelay } from '@/services/relay'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, + + errors: { + invalidUrl: { + message: 'Invalid URL', + code: 'INVALID_URL', + id: 'fb8c92d3-d4e5-44e7-b3d4-800d5cef8b2c' + }, + }, + + res: { + 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' + }, + inbox: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + status: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'requesting', + enum: [ + 'requesting', + 'accepted', + 'rejected' + ] + } + } + } +}; + +export default define(meta, async (ps, user) => { + try { + if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; + } catch { + throw new ApiError(meta.errors.invalidUrl); + } + + return await addRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts new file mode 100644 index 0000000000..031ebe85d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -0,0 +1,47 @@ +import define from '../../../define'; +import { listRelay } from '@/services/relay'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + }, + + 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, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + inbox: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + status: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'requesting', + enum: [ + 'requesting', + 'accepted', + 'rejected' + ] + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + return await listRelay(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts new file mode 100644 index 0000000000..c1c50f5dc0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -0,0 +1,20 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { removeRelay } from '@/services/relay'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, +}; + +export default define(meta, async (ps, user) => { + return await removeRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts new file mode 100644 index 0000000000..0fc2c6a868 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import * as bcrypt from 'bcryptjs'; +import rndstr from 'rndstr'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + password: { + type: 'string' as const, + optional: false as const, nullable: false as const, + minLength: 8, + maxLength: 8 + } + } + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot reset password of admin'); + } + + const passwd = rndstr('a-zA-Z0-9', 8); + + // Generate hash of password + const hash = bcrypt.hashSync(passwd); + + await UserProfiles.update({ + userId: user.id + }, { + password: hash + }); + + return { + password: passwd + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts new file mode 100644 index 0000000000..7b71f8e000 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { AbuseUserReports } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + reportId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const report = await AbuseUserReports.findOne(ps.reportId); + + if (report == null) { + throw new Error('report not found'); + } + + await AbuseUserReports.update(report.id, { + resolved: true, + assigneeId: me.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resync-chart.ts b/packages/backend/src/server/api/endpoints/admin/resync-chart.ts new file mode 100644 index 0000000000..e01dfce1b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resync-chart.ts @@ -0,0 +1,21 @@ +import define from '../../define'; +import { driveChart, notesChart, usersChart } from '@/services/chart/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + insertModerationLog(me, 'chartResync'); + + driveChart.resync(); + notesChart.resync(); + usersChart.resync(); + + // TODO: ユーザーごとのチャートもキューに入れて更新する + // TODO: インスタンスごとのチャートもキューに入れて更新する +}); diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts new file mode 100644 index 0000000000..6f67b78542 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; +import define from '../../define'; +import { sendEmail } from '@/services/send-email'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + to: { + validator: $.str, + }, + subject: { + validator: $.str, + }, + text: { + validator: $.str, + }, + } +}; + +export default define(meta, async (ps) => { + await sendEmail(ps.to, ps.subject, ps.text, ps.text); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts new file mode 100644 index 0000000000..bb2d35e397 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -0,0 +1,119 @@ +import * as os from 'os'; +import * as si from 'systeminformation'; +import { getConnection } from 'typeorm'; +import define from '../../define'; +import { redisClient } from '../../../../db/redis'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin', 'meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + machine: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + os: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'linux' + }, + node: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + psql: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + cpu: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + model: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + cores: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + }, + mem: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + } + } + }, + fs: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + }, + used: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + } + } + }, + net: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + interface: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'eth0' + } + } + } + } + } +}; + +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: redisClient.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/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts new file mode 100644 index 0000000000..e9509568d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ModerationLogs } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + 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, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + info: { + type: 'object' as const, + optional: false as const, nullable: false as const + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + const reports = await query.take(ps.limit!).getMany(); + + return await ModerationLogs.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts new file mode 100644 index 0000000000..963c123255 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -0,0 +1,177 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + nullable: false as const, optional: false as const, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'date-time' + }, + updatedAt: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'date-time' + }, + lastFetchedAt: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + username: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + name: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + folowersCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + followingCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + notesCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + avatarId: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + bannerId: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + tags: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + avatarUrl: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'url' + }, + bannerUrl: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'url' + }, + avatarBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + bannerBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + isSuspended: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isSilenced: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isLocked: { + type: 'boolean' as const, + nullable: false as const, optional: false as const, + }, + isBot: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isCat: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isAdmin: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isModerator: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + emojis: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + host: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + inbox: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + sharedInbox: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + featured: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + uri: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + token: { + type: 'string' as const, + nullable: false as const, optional: false as const, + default: '<MASKED>' + } + } + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if ((me.isModerator && !me.isAdmin) && user.isAdmin) { + throw new Error('cannot show info of admin'); + } + + return { + ...user, + token: user.token != null ? '<MASKED>' : user.token, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts new file mode 100644 index 0000000000..20b63e7be6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -0,0 +1,119 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'available', + 'admin', + 'moderator', + 'adminOrModerator', + 'silenced', + 'suspended', + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + }, + + username: { + validator: $.optional.str, + default: null + }, + + hostname: { + validator: $.optional.str, + default: null + } + }, + + res: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user'); + + switch (ps.state) { + case 'available': query.where('user.isSuspended = FALSE'); break; + case 'admin': query.where('user.isAdmin = TRUE'); break; + case 'moderator': query.where('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + case 'silenced': query.where('user.isSilenced = TRUE'); break; + case 'suspended': query.where('user.isSuspended = TRUE'); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.username) { + query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + } + + if (ps.hostname) { + query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + query.take(ps.limit!); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts new file mode 100644 index 0000000000..9bfed2310a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -0,0 +1,38 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot silence admin'); + } + + await Users.update(user.id, { + isSilenced: true + }); + + insertModerationLog(me, 'silence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts new file mode 100644 index 0000000000..364f258ce8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import deleteFollowing from '@/services/following/delete'; +import { Users, Followings, Notifications } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } + + await Users.update(user.id, { + isSuspended: true + }); + + insertModerationLog(me, 'suspend', { + targetId: user.id, + }); + + // Terminate streaming + if (Users.isLocalUser(user)) { + publishUserEvent(user.id, 'terminate', {}); + } + + (async () => { + await doPostSuspend(user).catch(e => {}); + await unFollowAll(user).catch(e => {}); + await readAllNotify(user).catch(e => {}); + })(); +}); + +async function unFollowAll(follower: User) { + const followings = await Followings.find({ + followerId: follower.id + }); + + for (const following of followings) { + const followee = await Users.findOne({ + id: following.followeeId + }); + + if (followee == null) { + throw `Cant find followee ${following.followeeId}`; + } + + await deleteFollowing(follower, followee, true); + } +} + +async function readAllNotify(notifier: User) { + await Notifications.update({ + notifierId: notifier.id, + isRead: false, + }, { + isRead: true + }); +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts new file mode 100644 index 0000000000..9994fbf462 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isSilenced: false + }); + + insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts new file mode 100644 index 0000000000..ab4c2d3dfe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { doPostUnsuspend } from '@/services/unsuspend-user'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isSuspended: false + }); + + insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); + + doPostUnsuspend(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts new file mode 100644 index 0000000000..55447098dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -0,0 +1,608 @@ +import $ from 'cafy'; +import define from '../../define'; +import { getConnection } from 'typeorm'; +import { Meta } from '@/models/entities/meta'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + disableRegistration: { + validator: $.optional.nullable.bool, + }, + + disableLocalTimeline: { + validator: $.optional.nullable.bool, + }, + + disableGlobalTimeline: { + validator: $.optional.nullable.bool, + }, + + useStarForReactionFallback: { + validator: $.optional.nullable.bool, + }, + + pinnedUsers: { + validator: $.optional.nullable.arr($.str), + }, + + hiddenTags: { + validator: $.optional.nullable.arr($.str), + }, + + blockedHosts: { + validator: $.optional.nullable.arr($.str), + }, + + mascotImageUrl: { + validator: $.optional.nullable.str, + }, + + bannerUrl: { + validator: $.optional.nullable.str, + }, + + errorImageUrl: { + validator: $.optional.nullable.str, + }, + + iconUrl: { + validator: $.optional.nullable.str, + }, + + backgroundImageUrl: { + validator: $.optional.nullable.str, + }, + + logoImageUrl: { + validator: $.optional.nullable.str, + }, + + name: { + validator: $.optional.nullable.str, + }, + + description: { + validator: $.optional.nullable.str, + }, + + maxNoteTextLength: { + validator: $.optional.num.min(0).max(DB_MAX_NOTE_TEXT_LENGTH), + }, + + localDriveCapacityMb: { + validator: $.optional.num.min(0), + }, + + remoteDriveCapacityMb: { + validator: $.optional.num.min(0), + }, + + cacheRemoteFiles: { + validator: $.optional.bool, + }, + + proxyRemoteFiles: { + validator: $.optional.bool, + }, + + emailRequiredForSignup: { + validator: $.optional.bool, + }, + + enableHcaptcha: { + validator: $.optional.bool, + }, + + hcaptchaSiteKey: { + validator: $.optional.nullable.str, + }, + + hcaptchaSecretKey: { + validator: $.optional.nullable.str, + }, + + enableRecaptcha: { + validator: $.optional.bool, + }, + + recaptchaSiteKey: { + validator: $.optional.nullable.str, + }, + + recaptchaSecretKey: { + validator: $.optional.nullable.str, + }, + + proxyAccountId: { + validator: $.optional.nullable.type(ID), + }, + + maintainerName: { + validator: $.optional.nullable.str, + }, + + maintainerEmail: { + validator: $.optional.nullable.str, + }, + + pinnedPages: { + validator: $.optional.arr($.str), + }, + + pinnedClipId: { + validator: $.optional.nullable.type(ID), + }, + + langs: { + validator: $.optional.arr($.str), + }, + + summalyProxy: { + validator: $.optional.nullable.str, + }, + + deeplAuthKey: { + validator: $.optional.nullable.str, + }, + + deeplIsPro: { + validator: $.optional.bool, + }, + + enableTwitterIntegration: { + validator: $.optional.bool, + }, + + twitterConsumerKey: { + validator: $.optional.nullable.str, + }, + + twitterConsumerSecret: { + validator: $.optional.nullable.str, + }, + + enableGithubIntegration: { + validator: $.optional.bool, + }, + + githubClientId: { + validator: $.optional.nullable.str, + }, + + githubClientSecret: { + validator: $.optional.nullable.str, + }, + + enableDiscordIntegration: { + validator: $.optional.bool, + }, + + discordClientId: { + validator: $.optional.nullable.str, + }, + + discordClientSecret: { + validator: $.optional.nullable.str, + }, + + enableEmail: { + validator: $.optional.bool, + }, + + email: { + validator: $.optional.nullable.str, + }, + + smtpSecure: { + validator: $.optional.bool, + }, + + smtpHost: { + validator: $.optional.nullable.str, + }, + + smtpPort: { + validator: $.optional.nullable.num, + }, + + smtpUser: { + validator: $.optional.nullable.str, + }, + + smtpPass: { + validator: $.optional.nullable.str, + }, + + enableServiceWorker: { + validator: $.optional.bool, + }, + + swPublicKey: { + validator: $.optional.nullable.str, + }, + + swPrivateKey: { + validator: $.optional.nullable.str, + }, + + tosUrl: { + validator: $.optional.nullable.str, + }, + + repositoryUrl: { + validator: $.optional.str, + }, + + feedbackUrl: { + validator: $.optional.str, + }, + + useObjectStorage: { + validator: $.optional.bool + }, + + objectStorageBaseUrl: { + validator: $.optional.nullable.str + }, + + objectStorageBucket: { + validator: $.optional.nullable.str + }, + + objectStoragePrefix: { + validator: $.optional.nullable.str + }, + + objectStorageEndpoint: { + validator: $.optional.nullable.str + }, + + objectStorageRegion: { + validator: $.optional.nullable.str + }, + + objectStoragePort: { + validator: $.optional.nullable.num + }, + + objectStorageAccessKey: { + validator: $.optional.nullable.str + }, + + objectStorageSecretKey: { + validator: $.optional.nullable.str + }, + + objectStorageUseSSL: { + validator: $.optional.bool + }, + + objectStorageUseProxy: { + validator: $.optional.bool + }, + + objectStorageSetPublicRead: { + validator: $.optional.bool + }, + + objectStorageS3ForcePathStyle: { + validator: $.optional.bool + }, + } +}; + +export default define(meta, async (ps, me) => { + const set = {} as Partial<Meta>; + + if (typeof ps.disableRegistration === 'boolean') { + set.disableRegistration = ps.disableRegistration; + } + + if (typeof ps.disableLocalTimeline === 'boolean') { + set.disableLocalTimeline = ps.disableLocalTimeline; + } + + if (typeof ps.disableGlobalTimeline === 'boolean') { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } + + if (typeof ps.useStarForReactionFallback === 'boolean') { + set.useStarForReactionFallback = ps.useStarForReactionFallback; + } + + if (Array.isArray(ps.pinnedUsers)) { + set.pinnedUsers = ps.pinnedUsers.filter(Boolean); + } + + if (Array.isArray(ps.hiddenTags)) { + set.hiddenTags = ps.hiddenTags.filter(Boolean); + } + + if (Array.isArray(ps.blockedHosts)) { + set.blockedHosts = ps.blockedHosts.filter(Boolean); + } + + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } + + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } + + if (ps.iconUrl !== undefined) { + set.iconUrl = ps.iconUrl; + } + + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + + if (ps.name !== undefined) { + set.name = ps.name; + } + + if (ps.description !== undefined) { + set.description = ps.description; + } + + if (ps.maxNoteTextLength) { + set.maxNoteTextLength = ps.maxNoteTextLength; + } + + if (ps.localDriveCapacityMb !== undefined) { + set.localDriveCapacityMb = ps.localDriveCapacityMb; + } + + if (ps.remoteDriveCapacityMb !== undefined) { + set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; + } + + if (ps.cacheRemoteFiles !== undefined) { + set.cacheRemoteFiles = ps.cacheRemoteFiles; + } + + if (ps.proxyRemoteFiles !== undefined) { + set.proxyRemoteFiles = ps.proxyRemoteFiles; + } + + if (ps.emailRequiredForSignup !== undefined) { + set.emailRequiredForSignup = ps.emailRequiredForSignup; + } + + if (ps.enableHcaptcha !== undefined) { + set.enableHcaptcha = ps.enableHcaptcha; + } + + if (ps.hcaptchaSiteKey !== undefined) { + set.hcaptchaSiteKey = ps.hcaptchaSiteKey; + } + + if (ps.hcaptchaSecretKey !== undefined) { + set.hcaptchaSecretKey = ps.hcaptchaSecretKey; + } + + if (ps.enableRecaptcha !== undefined) { + set.enableRecaptcha = ps.enableRecaptcha; + } + + if (ps.recaptchaSiteKey !== undefined) { + set.recaptchaSiteKey = ps.recaptchaSiteKey; + } + + if (ps.recaptchaSecretKey !== undefined) { + set.recaptchaSecretKey = ps.recaptchaSecretKey; + } + + if (ps.proxyAccountId !== undefined) { + set.proxyAccountId = ps.proxyAccountId; + } + + if (ps.maintainerName !== undefined) { + set.maintainerName = ps.maintainerName; + } + + if (ps.maintainerEmail !== undefined) { + set.maintainerEmail = ps.maintainerEmail; + } + + if (Array.isArray(ps.langs)) { + set.langs = ps.langs.filter(Boolean); + } + + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } + + if (ps.pinnedClipId !== undefined) { + set.pinnedClipId = ps.pinnedClipId; + } + + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } + + if (ps.enableTwitterIntegration !== undefined) { + set.enableTwitterIntegration = ps.enableTwitterIntegration; + } + + if (ps.twitterConsumerKey !== undefined) { + set.twitterConsumerKey = ps.twitterConsumerKey; + } + + if (ps.twitterConsumerSecret !== undefined) { + set.twitterConsumerSecret = ps.twitterConsumerSecret; + } + + if (ps.enableGithubIntegration !== undefined) { + set.enableGithubIntegration = ps.enableGithubIntegration; + } + + if (ps.githubClientId !== undefined) { + set.githubClientId = ps.githubClientId; + } + + if (ps.githubClientSecret !== undefined) { + set.githubClientSecret = ps.githubClientSecret; + } + + if (ps.enableDiscordIntegration !== undefined) { + set.enableDiscordIntegration = ps.enableDiscordIntegration; + } + + if (ps.discordClientId !== undefined) { + set.discordClientId = ps.discordClientId; + } + + if (ps.discordClientSecret !== undefined) { + set.discordClientSecret = ps.discordClientSecret; + } + + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } + + if (ps.email !== undefined) { + set.email = ps.email; + } + + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } + + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } + + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } + + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } + + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } + + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.enableServiceWorker !== undefined) { + set.enableServiceWorker = ps.enableServiceWorker; + } + + if (ps.swPublicKey !== undefined) { + set.swPublicKey = ps.swPublicKey; + } + + if (ps.swPrivateKey !== undefined) { + set.swPrivateKey = ps.swPrivateKey; + } + + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; + } + + if (ps.repositoryUrl !== undefined) { + set.repositoryUrl = ps.repositoryUrl; + } + + if (ps.feedbackUrl !== undefined) { + set.feedbackUrl = ps.feedbackUrl; + } + + if (ps.useObjectStorage !== undefined) { + set.useObjectStorage = ps.useObjectStorage; + } + + if (ps.objectStorageBaseUrl !== undefined) { + set.objectStorageBaseUrl = ps.objectStorageBaseUrl; + } + + if (ps.objectStorageBucket !== undefined) { + set.objectStorageBucket = ps.objectStorageBucket; + } + + if (ps.objectStoragePrefix !== undefined) { + set.objectStoragePrefix = ps.objectStoragePrefix; + } + + if (ps.objectStorageEndpoint !== undefined) { + set.objectStorageEndpoint = ps.objectStorageEndpoint; + } + + if (ps.objectStorageRegion !== undefined) { + set.objectStorageRegion = ps.objectStorageRegion; + } + + if (ps.objectStoragePort !== undefined) { + set.objectStoragePort = ps.objectStoragePort; + } + + if (ps.objectStorageAccessKey !== undefined) { + set.objectStorageAccessKey = ps.objectStorageAccessKey; + } + + if (ps.objectStorageSecretKey !== undefined) { + set.objectStorageSecretKey = ps.objectStorageSecretKey; + } + + if (ps.objectStorageUseSSL !== undefined) { + set.objectStorageUseSSL = ps.objectStorageUseSSL; + } + + if (ps.objectStorageUseProxy !== undefined) { + set.objectStorageUseProxy = ps.objectStorageUseProxy; + } + + if (ps.objectStorageSetPublicRead !== undefined) { + set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; + } + + if (ps.objectStorageS3ForcePathStyle !== undefined) { + set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; + } + + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === '') { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + + if (ps.deeplIsPro !== undefined) { + set.deeplIsPro = ps.deeplIsPro; + } + + await getConnection().transaction(async transactionalEntityManager => { + const meta = await transactionalEntityManager.findOne(Meta, { + order: { + id: 'DESC' + } + }); + + if (meta) { + await transactionalEntityManager.update(Meta, meta.id, set); + } else { + await transactionalEntityManager.save(Meta, set); + } + }); + + insertModerationLog(me, 'updateMeta'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/vacuum.ts b/packages/backend/src/server/api/endpoints/admin/vacuum.ts new file mode 100644 index 0000000000..9a80d88c44 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/vacuum.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../define'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + full: { + validator: $.bool, + }, + analyze: { + validator: $.bool, + }, + } +}; + +export default define(meta, async (ps, me) => { + const params: string[] = []; + + if (ps.full) { + params.push('FULL'); + } + + if (ps.analyze) { + params.push('ANALYZE'); + } + + getConnection().query('VACUUM ' + params.join(' ')); + + insertModerationLog(me, 'vacuum', ps); +}); |