diff options
| author | Julia <julia@insertdomain.name> | 2024-12-31 02:30:13 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2024-12-31 02:30:13 +0000 |
| commit | 4c0bbddd0fba7e0d76fb484312e691ee29fe5858 (patch) | |
| tree | 4bb1a3a2a79c679ac021a2199bd526be469524d4 /packages/backend/src/server/api | |
| parent | merge: fixes for 2024.9.4 (if we want to) (!770) (diff) | |
| parent | Bump version (diff) | |
| download | sharkey-4c0bbddd0fba7e0d76fb484312e691ee29fe5858.tar.gz sharkey-4c0bbddd0fba7e0d76fb484312e691ee29fe5858.tar.bz2 sharkey-4c0bbddd0fba7e0d76fb484312e691ee29fe5858.zip | |
merge: Bump stable version (!842)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/842
Diffstat (limited to 'packages/backend/src/server/api')
53 files changed, 1575 insertions, 253 deletions
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 6f51825494..03f25a51fe 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -18,8 +18,9 @@ import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; +import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { ApiError } from './error.js'; -import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @@ -49,7 +50,7 @@ export class ApiCallService implements OnApplicationShutdown { private userIpsRepository: UserIpsRepository, private authenticateService: AuthenticateService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private roleService: RoleService, private apiLoggerService: ApiLoggerService, ) { @@ -65,16 +66,6 @@ export class ApiCallService implements OnApplicationShutdown { let statusCode = err.httpStatusCode; if (err.httpStatusCode === 401) { reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); - } else if (err.code === 'RATE_LIMIT_EXCEEDED') { - const info: unknown = err.info; - const unixEpochInSeconds = Date.now(); - if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') { - const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000); - // もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく - reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); - } else { - this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); - } } else if (err.kind === 'client') { reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); statusCode = statusCode ?? 400; @@ -168,7 +159,7 @@ export class ApiCallService implements OnApplicationShutdown { return; } this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, body, null, request).then((res) => { + this.call(endpoint, user, app, body, null, request, reply).then((res) => { if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); } @@ -229,7 +220,7 @@ export class ApiCallService implements OnApplicationShutdown { this.call(endpoint, user, app, fields, { name: multipartData.filename, path: path, - }, request).then((res) => { + }, request, reply).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { this.#sendApiError(reply, err); @@ -304,6 +295,7 @@ export class ApiCallService implements OnApplicationShutdown { path: string; } | null, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, + reply: FastifyReply, ) { const isSecure = user != null && token == null; @@ -312,7 +304,7 @@ export class ApiCallService implements OnApplicationShutdown { } // For endpoints without a limit, the default is 10 calls per second - const endpointLimit: IEndpointMeta['limit'] = ep.meta.limit ?? { + const endpointLimit = ep.meta.limit ?? { duration: 1000, max: 10, }; @@ -328,30 +320,28 @@ export class ApiCallService implements OnApplicationShutdown { limitActor = getIpHash(request.ip); } - const limit = Object.assign({}, endpointLimit); - - if (limit.key == null) { - (limit as any).key = ep.name; - } - // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; if (factor > 0) { + const limit = { + key: ep.name, + ...endpointLimit, + }; + // Rate limit - await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => { - if ('info' in err) { - // errはLimiter.LimiterInfoであることが期待される - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }, err.info); - } else { - throw new TypeError('information must be a rate-limiter information.'); - } - }); + const info = await this.rateLimiterService.limit(limit, limitActor, factor); + + sendRateLimitHeaders(reply, info); + + if (info.blocked) { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }, info); + } } } diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index ac3b982742..0d77309537 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -119,25 +119,30 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'frc-captcha-solution'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); fastify.post<{ Body: { username: string; - password: string; + password?: string; token?: string; - signature?: string; - authenticatorData?: string; - clientDataJSON?: string; - credentialId?: string; - challengeId?: string; + credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'frc-captcha-solution'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; - }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); + }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); fastify.post<{ Body: { credential?: AuthenticationResponseJSON; + context?: string; }; }>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply)); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bdd7cf650..e319d6e0a4 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -68,6 +68,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; +import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js'; +import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js'; import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; @@ -311,6 +313,9 @@ import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; import * as ep___notes_edit from './endpoints/notes/edit.js'; import * as ep___notes_versions from './endpoints/notes/versions.js'; +import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -470,6 +475,8 @@ const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; +const $admin_forwardAbuseUserReport: Provider = { provide: 'ep:admin/forward-abuse-user-report', useClass: ep___admin_forwardAbuseUserReport.default }; +const $admin_updateAbuseUserReport: Provider = { provide: 'ep:admin/update-abuse-user-report', useClass: ep___admin_updateAbuseUserReport.default }; const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; @@ -711,6 +718,9 @@ const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete' const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.default }; const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; +const $notes_schedule_create: Provider = { provide: 'ep:notes/schedule/create', useClass: ep___notes_schedule_create.default }; +const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default }; +const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default }; const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; @@ -876,6 +886,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_relays_remove, $admin_resetPassword, $admin_resolveAbuseUserReport, + $admin_forwardAbuseUserReport, + $admin_updateAbuseUserReport, $admin_sendEmail, $admin_serverInfo, $admin_showModerationLogs, @@ -1117,6 +1129,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_like, $notes_renotes, $notes_replies, + $notes_schedule_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_searchByTag, $notes_search, $notes_show, @@ -1276,6 +1291,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_relays_remove, $admin_resetPassword, $admin_resolveAbuseUserReport, + $admin_forwardAbuseUserReport, + $admin_updateAbuseUserReport, $admin_sendEmail, $admin_serverInfo, $admin_showModerationLogs, @@ -1516,6 +1533,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_like, $notes_renotes, $notes_replies, + $notes_schedule_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_searchByTag, $notes_search, $notes_show, diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index 8643be0f30..419017aaf4 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -42,6 +42,17 @@ export class GetterService { return note; } + @bindThis + public async getNoteWithUser(noteId: MiNote['id']) { + const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] }); + + if (note == null) { + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + } + + return note; + } + /** * Get note for API processing */ diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index e9afb9d05a..879529090f 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -10,8 +10,10 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { LegacyRateLimit } from '@/misc/rate-limit-utils.js'; import type { IEndpointMeta } from './endpoints.js'; +/** @deprecated Use SkRateLimiterService instead */ @Injectable() export class RateLimiterService { private logger: Logger; @@ -31,7 +33,7 @@ export class RateLimiterService { } @bindThis - public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) { + public limit(limitation: LegacyRateLimit & { key: NonNullable<string> }, actor: string, factor = 1) { return new Promise<void>((ok, reject) => { if (this.disabled) ok(); diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 64af7da7a6..fa9155d82d 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -6,12 +6,14 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import * as argon2 from 'argon2'; -import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; +import * as Misskey from 'misskey-js'; import { DI } from '@/di-symbols.js'; import type { + MiMeta, SigninsRepository, UserProfilesRepository, + UserSecurityKeysRepository, UsersRepository, } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -21,12 +23,14 @@ import { IdService } from '@/core/IdService.js'; import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import { RateLimiterService } from './RateLimiterService.js'; +import { CaptchaService } from '@/core/CaptchaService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; +import { isSystemAccount } from '@/misc/is-system-account.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { isSystemAccount } from '@/misc/is-system-account.js'; -import type { MiMeta } from '@/models/_.js'; @Injectable() export class SigninApiService { @@ -43,14 +47,18 @@ export class SigninApiService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, private idService: IdService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private signinService: SigninService, private userAuthService: UserAuthService, private webAuthnService: WebAuthnService, + private captchaService: CaptchaService, ) { } @@ -59,9 +67,15 @@ export class SigninApiService { request: FastifyRequest<{ Body: { username: string; - password: string; + password?: string; token?: string; credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'frc-captcha-solution'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>, reply: FastifyReply, @@ -79,10 +93,12 @@ export class SigninApiService { return { error }; } - try { // not more than 1 attempt per second and not more than 10 attempts per hour - await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); - } catch (err) { + const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); + + sendRateLimitHeaders(reply, rateLimit); + + if (rateLimit.blocked) { reply.code(429); return { error: { @@ -98,11 +114,6 @@ export class SigninApiService { return; } - if (typeof password !== 'string') { - reply.code(400); - return; - } - if (token != null && typeof token !== 'string') { reply.code(400); return; @@ -133,6 +144,27 @@ export class SigninApiService { } const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1); + + if (password == null) { + reply.code(200); + if (profile.twoFactorEnabled) { + return { + finished: false, + next: 'password', + } satisfies Misskey.entities.SigninFlowResponse; + } else { + return { + finished: false, + next: 'captcha', + } satisfies Misskey.entities.SigninFlowResponse; + } + } + + if (typeof password !== 'string') { + reply.code(400); + return; + } if (!user.approved && this.meta.approvalRequiredForSignup) { reply.code(403); @@ -148,7 +180,7 @@ export class SigninApiService { // Compare password const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!); - const fail = async (status?: number, failure?: { id: string }) => { + const fail = async (status?: number, failure?: { id: string; }) => { // Append signin history await this.signinsRepository.insert({ id: this.idService.gen(), @@ -162,6 +194,44 @@ export class SigninApiService { }; if (!profile.twoFactorEnabled) { + if (process.env.NODE_ENV !== 'test') { + if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableFC && this.meta.fcSecretKey) { + await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + } + if (same) { if (profile.password!.startsWith('$2')) { const newHash = await argon2.hash(password); @@ -220,7 +290,7 @@ export class SigninApiService { id: '93b86c4b-72f9-40eb-9815-798928603d1e', }); } - } else { + } else if (securityKeysAvailable) { if (!same && !profile.usePasswordLessLogin) { return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', @@ -230,7 +300,23 @@ export class SigninApiService { const authRequest = await this.webAuthnService.initiateAuthentication(user.id); reply.code(200); - return authRequest; + return { + finished: false, + next: 'passkey', + authRequest, + } satisfies Misskey.entities.SigninFlowResponse; + } else { + if (!same || !profile.twoFactorEnabled) { + return await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } else { + reply.code(200); + return { + finished: false, + next: 'totp', + } satisfies Misskey.entities.SigninFlowResponse; + } } // never get here } diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 70306c3113..640356b50c 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -4,13 +4,16 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import * as Misskey from 'misskey-js'; import { DI } from '@/di-symbols.js'; -import type { SigninsRepository } from '@/models/_.js'; +import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; import { bindThis } from '@/decorators.js'; +import { EmailService } from '@/core/EmailService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -19,7 +22,12 @@ export class SigninService { @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private signinEntityService: SigninEntityService, + private emailService: EmailService, + private notificationService: NotificationService, private idService: IdService, private globalEventService: GlobalEventService, ) { @@ -28,7 +36,8 @@ export class SigninService { @bindThis public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) { setImmediate(async () => { - // Append signin history + this.notificationService.createNotification(user.id, 'login', {}); + const record = await this.signinsRepository.insertOne({ id: this.idService.gen(), userId: user.id, @@ -37,15 +46,22 @@ export class SigninService { success: true, }); - // Publish signin event this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, 'New login / ログインがありました', + 'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。', + 'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。'); + } }); reply.code(200); return { + finished: true, id: user.id, - i: user.token, - }; + i: user.token!, + } satisfies Misskey.entities.SigninFlowResponse; } } diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 9ba23c54e2..e94d2b6b68 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -21,7 +21,8 @@ import { WebAuthnService } from '@/core/WebAuthnService.js'; import Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IdentifiableError } from '@/misc/identifiable-error.js'; -import { RateLimiterService } from './RateLimiterService.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -43,7 +44,7 @@ export class SigninWithPasskeyApiService { private signinsRepository: SigninsRepository, private idService: IdService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private signinService: SigninService, private webAuthnService: WebAuthnService, private loggerService: LoggerService, @@ -84,11 +85,13 @@ export class SigninWithPasskeyApiService { return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); }; - try { - // Not more than 1 API call per 250ms and not more than 100 attempts per 30min - // NOTE: 1 Sign-in require 2 API calls - await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); - } catch (err) { + // Not more than 1 API call per 250ms and not more than 100 attempts per 30min + // NOTE: 1 Sign-in require 2 API calls + const rateLimit = await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); + + sendRateLimitHeaders(reply, rateLimit); + + if (rateLimit.blocked) { reply.code(429); return { error: { diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index db860d710a..7aea6a0e56 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -73,6 +73,7 @@ export class SignupApiService { 'turnstile-response'?: string; 'm-captcha-response'?: string; 'frc-captcha-solution'?: string; + 'testcaptcha-response'?: string; } }>, reply: FastifyReply, @@ -111,6 +112,12 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } const username = body['username']; diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts new file mode 100644 index 0000000000..38c97b63df --- /dev/null +++ b/packages/backend/src/server/api/SkRateLimiterService.ts @@ -0,0 +1,253 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { TimeService } from '@/core/TimeService.js'; +import { EnvService } from '@/core/EnvService.js'; +import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed, hasMaxLimit, disabledLimitInfo, MaxLegacyLimit, MinLegacyLimit } from '@/misc/rate-limit-utils.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class SkRateLimiterService { + private readonly disabled: boolean; + + constructor( + @Inject(TimeService) + private readonly timeService: TimeService, + + @Inject(DI.redis) + private readonly redisClient: Redis.Redis, + + @Inject(EnvService) + envService: EnvService, + ) { + this.disabled = envService.env.NODE_ENV === 'test'; + } + + /** + * Check & increment a rate limit + * @param limit The limit definition + * @param actor Client who is calling this limit + * @param factor Scaling factor - smaller = larger limit (less restrictive) + */ + public async limit(limit: Keyed<RateLimit>, actor: string, factor = 1): Promise<LimitInfo> { + if (this.disabled || factor === 0) { + return disabledLimitInfo; + } + + if (factor < 0) { + throw new Error(`Rate limit factor is zero or negative: ${factor}`); + } + + return await this.tryLimit(limit, actor, factor); + } + + private async tryLimit(limit: Keyed<RateLimit>, actor: string, factor: number): Promise<LimitInfo> { + if (isLegacyRateLimit(limit)) { + return await this.limitLegacy(limit, actor, factor); + } else { + return await this.limitBucket(limit, actor, factor); + } + } + + private async limitLegacy(limit: Keyed<LegacyRateLimit>, actor: string, factor: number): Promise<LimitInfo> { + if (hasMaxLimit(limit)) { + return await this.limitLegacyMinMax(limit, actor, factor); + } else if (hasMinLimit(limit)) { + return await this.limitLegacyMinOnly(limit, actor, factor); + } else { + return disabledLimitInfo; + } + } + + private async limitLegacyMinMax(limit: Keyed<MaxLegacyLimit>, actor: string, factor: number): Promise<LimitInfo> { + if (limit.duration === 0) return disabledLimitInfo; + if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`); + if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`); + + // Derive initial dripRate from minInterval OR duration/max. + const initialDripRate = Math.max(limit.minInterval ?? Math.round(limit.duration / limit.max), 1); + + // Calculate dripSize to reach max at exactly duration + const dripSize = Math.max(Math.round(limit.max / (limit.duration / initialDripRate)), 1); + + // Calculate final dripRate from dripSize and duration/max + const dripRate = Math.max(Math.round(limit.duration / (limit.max / dripSize)), 1); + + const bucketLimit: Keyed<BucketRateLimit> = { + type: 'bucket', + key: limit.key, + size: limit.max, + dripRate, + dripSize, + }; + return await this.limitBucket(bucketLimit, actor, factor); + } + + private async limitLegacyMinOnly(limit: Keyed<MinLegacyLimit>, actor: string, factor: number): Promise<LimitInfo> { + if (limit.minInterval === 0) return disabledLimitInfo; + if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`); + + const dripRate = Math.max(Math.round(limit.minInterval), 1); + const bucketLimit: Keyed<BucketRateLimit> = { + type: 'bucket', + key: limit.key, + size: 1, + dripRate, + dripSize: 1, + }; + return await this.limitBucket(bucketLimit, actor, factor); + } + + /** + * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details. + */ + private async limitBucket(limit: Keyed<BucketRateLimit>, actor: string, factor: number): Promise<LimitInfo> { + if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`); + if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`); + if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`); + + // 0 - Calculate + const now = this.timeService.now; + const bucketSize = Math.max(Math.ceil(limit.size / factor), 1); + const dripRate = Math.ceil(limit.dripRate ?? 1000); + const dripSize = Math.ceil(limit.dripSize ?? 1); + const expirationSec = Math.max(Math.ceil((dripRate * Math.ceil(bucketSize / dripSize)) / 1000), 1); + + // 1 - Read + const counterKey = createLimitKey(limit, actor, 'c'); + const timestampKey = createLimitKey(limit, actor, 't'); + const counter = await this.getLimitCounter(counterKey, timestampKey); + + // 2 - Drip + const dripsSinceLastTick = Math.floor((now - counter.timestamp) / dripRate) * dripSize; + const deltaCounter = Math.min(dripsSinceLastTick, counter.counter); + const deltaTimestamp = dripsSinceLastTick * dripRate; + if (deltaCounter > 0) { + // Execute the next drip(s) + const results = await this.executeRedisMulti( + ['get', timestampKey], + ['incrby', timestampKey, deltaTimestamp], + ['expire', timestampKey, expirationSec], + ['get', timestampKey], + ['decrby', counterKey, deltaCounter], + ['expire', counterKey, expirationSec], + ['get', counterKey], + ); + const expectedTimestamp = counter.timestamp; + const canaryTimestamp = results[0] ? parseInt(results[0]) : 0; + counter.timestamp = results[3] ? parseInt(results[3]) : 0; + counter.counter = results[6] ? parseInt(results[6]) : 0; + + // Check for a data collision and rollback + if (canaryTimestamp !== expectedTimestamp) { + const rollbackResults = await this.executeRedisMulti( + ['decrby', timestampKey, deltaTimestamp], + ['get', timestampKey], + ['incrby', counterKey, deltaCounter], + ['get', counterKey], + ); + counter.timestamp = rollbackResults[1] ? parseInt(rollbackResults[1]) : 0; + counter.counter = rollbackResults[3] ? parseInt(rollbackResults[3]) : 0; + } + } + + // 3 - Check + const blocked = counter.counter >= bucketSize; + if (!blocked) { + if (counter.timestamp === 0) { + const results = await this.executeRedisMulti( + ['set', timestampKey, now], + ['expire', timestampKey, expirationSec], + ['incr', counterKey], + ['expire', counterKey, expirationSec], + ['get', counterKey], + ); + counter.timestamp = now; + counter.counter = results[4] ? parseInt(results[4]) : 0; + } else { + const results = await this.executeRedisMulti( + ['incr', counterKey], + ['expire', counterKey, expirationSec], + ['get', counterKey], + ); + counter.counter = results[2] ? parseInt(results[2]) : 0; + } + } + + // Calculate how much time is needed to free up a bucket slot + const overflow = Math.max((counter.counter + 1) - bucketSize, 0); + const dripsNeeded = Math.ceil(overflow / dripSize); + const timeNeeded = Math.max((dripRate * dripsNeeded) - (this.timeService.now - counter.timestamp), 0); + + // Calculate limit status + const remaining = Math.max(bucketSize - counter.counter, 0); + const resetMs = timeNeeded; + const resetSec = Math.ceil(resetMs / 1000); + const fullResetMs = Math.ceil(counter.counter / dripSize) * dripRate; + const fullResetSec = Math.ceil(fullResetMs / 1000); + return { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs }; + } + + private async getLimitCounter(counterKey: string, timestampKey: string): Promise<LimitCounter> { + const [counter, timestamp] = await this.executeRedisMulti( + ['get', counterKey], + ['get', timestampKey], + ); + + return { + counter: counter ? parseInt(counter) : 0, + timestamp: timestamp ? parseInt(timestamp) : 0, + }; + } + + private async executeRedisMulti(...batch: RedisCommand[]): Promise<RedisResult[]> { + const results = await this.redisClient.multi(batch).exec(); + + // Transaction conflict (retryable) + if (!results) { + throw new ConflictError('Redis error: transaction conflict'); + } + + // Transaction failed (fatal) + if (results.length !== batch.length) { + throw new Error('Redis error: failed to execute batch'); + } + + // Map responses + const errors: Error[] = []; + const responses: RedisResult[] = []; + for (const [error, response] of results) { + if (error) errors.push(error); + responses.push(response as RedisResult); + } + + // Command failed (fatal) + if (errors.length > 0) { + const errorMessages = errors + .map((e, i) => `Error in command ${i}: ${e}`) + .join('\', \''); + throw new AggregateError(errors, `Redis error: failed to execute command(s): '${errorMessages}'`); + } + + return responses; + } +} + +// Not correct, but good enough for the basic commands we use. +type RedisResult = string | null; +type RedisCommand = [command: string, ...args: unknown[]]; + +function createLimitKey(limit: Keyed<RateLimit>, actor: string, value: string): string { + return `rl_${actor}_${limit.key}_${value}`; +} + +class ConflictError extends Error {} + +interface LimitCounter { + timestamp: number; + counter: number; +} diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 9b8464f705..e3fd1312ae 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -7,6 +7,8 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; +import proxyAddr from 'proxy-addr'; +import ms from 'ms'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, MiAccessToken } from '@/models/_.js'; import { NoteReadService } from '@/core/NoteReadService.js'; @@ -16,18 +18,15 @@ import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; -import { RateLimiterService } from './RateLimiterService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { getIpHash } from '@/misc/get-ip-hash.js'; -import proxyAddr from 'proxy-addr'; -import ms from 'ms'; import type * as http from 'node:http'; import type { IEndpointMeta } from './endpoints.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import type Logger from '@/logger.js'; @Injectable() export class StreamingApiServerService { @@ -49,7 +48,7 @@ export class StreamingApiServerService { private notificationService: NotificationService, private usersService: UserService, private channelFollowingService: ChannelFollowingService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private roleService: RoleService, private loggerService: LoggerService, ) { @@ -73,9 +72,8 @@ export class StreamingApiServerService { if (factor <= 0) return false; // Rate limit - return await this.rateLimiterService.limit(limit, limitActor, factor) - .then(() => { return false; }) - .catch(err => { return true; }); + const rateLimit = await this.rateLimiterService.limit(limit, limitActor, factor); + return rateLimit.blocked; } @bindThis diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 14e002929a..b4f36234f0 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -5,6 +5,7 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; +import type { RateLimit } from '@/misc/rate-limit-utils.js'; import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; @@ -74,6 +75,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; +import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js'; +import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js'; import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; @@ -315,6 +318,9 @@ import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete import * as ep___notes_like from './endpoints/notes/like.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -474,6 +480,8 @@ const eps = [ ['admin/relays/remove', ep___admin_relays_remove], ['admin/reset-password', ep___admin_resetPassword], ['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport], + ['admin/forward-abuse-user-report', ep___admin_forwardAbuseUserReport], + ['admin/update-abuse-user-report', ep___admin_updateAbuseUserReport], ['admin/send-email', ep___admin_sendEmail], ['admin/server-info', ep___admin_serverInfo], ['admin/show-moderation-logs', ep___admin_showModerationLogs], @@ -715,6 +723,9 @@ const eps = [ ['notes/like', ep___notes_like], ['notes/renotes', ep___notes_renotes], ['notes/replies', ep___notes_replies], + ['notes/schedule/create', ep___notes_schedule_create], + ['notes/schedule/delete', ep___notes_schedule_delete], + ['notes/schedule/list', ep___notes_schedule_list], ['notes/search-by-tag', ep___notes_searchByTag], ['notes/search', ep___notes_search], ['notes/show', ep___notes_show], @@ -855,30 +866,7 @@ interface IEndpointMetaBase { * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 */ - readonly limit?: { - - /** - * 複数のエンドポイントでリミットを共有したい場合に指定するキー - */ - readonly key?: string; - - /** - * リミットを適用する期間(ms) - * このプロパティを設定する場合、max プロパティも設定する必要があります。 - */ - readonly duration?: number; - - /** - * durationで指定した期間内にいくつまでリクエストできるのか - * このプロパティを設定する場合、duration プロパティも設定する必要があります。 - */ - readonly max?: number; - - /** - * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) - */ - readonly minInterval?: number; - }; + readonly limit?: Readonly<RateLimit>; /** * ファイルの添付を必要とするか否か 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 index cf3f257ca6..0dbfaae054 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -71,9 +71,22 @@ export const meta = { }, assignee: { type: 'object', - nullable: true, optional: true, + nullable: true, optional: false, ref: 'UserDetailedNotMe', }, + forwarded: { + type: 'boolean', + nullable: false, optional: false, + }, + resolvedAs: { + type: 'string', + nullable: true, optional: false, + enum: ['accept', 'reject', null], + }, + moderationNote: { + type: 'string', + nullable: false, optional: false, + }, }, }, }, @@ -88,7 +101,6 @@ export const paramDef = { state: { type: 'string', nullable: true, default: null }, reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, - forwarded: { type: 'boolean', default: false }, }, required: [], } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 7754899b95..53b1c4c4ec 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -3,33 +3,36 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; import { MiAccessToken, MiUser } from '@/models/_.js'; import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import { localUsernameSchema, passwordSchema } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { ApiError } from '@/server/api/error.js'; import { Packed } from '@/misc/json-schema.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '@/server/api/error.js'; export const meta = { tags: ['admin'], - res: { - type: 'object', - optional: false, nullable: false, - ref: 'MeDetailed', - properties: { - token: { - type: 'string', - optional: false, nullable: false, - }, + errors: { + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fff-b8df-057708cce513', + }, + + wrongInitialPassword: { + message: 'Initial password is incorrect.', + code: 'INCORRECT_INITIAL_PASSWORD', + id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62', }, - }, - errors: { // From ApiCallService.ts noCredential: { message: 'Credential required.', @@ -51,6 +54,18 @@ export const meta = { }, }, + res: { + type: 'object', + optional: false, nullable: false, + ref: 'MeDetailed', + properties: { + token: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + // Required token permissions, but we need to check them manually. // ApiCallService checks access in a way that would prevent creating the first account. softPermissions: [ @@ -64,6 +79,7 @@ export const paramDef = { properties: { username: localUsernameSchema, password: passwordSchema, + setupPassword: { type: 'string', nullable: true }, }, required: ['username', 'password'], } as const; @@ -71,13 +87,49 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private roleService: RoleService, private userEntityService: UserEntityService, private signupService: SignupService, private instanceActorService: InstanceActorService, ) { super(meta, paramDef, async (ps, _me, token) => { - await this.ensurePermissions(_me, token); + const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; + const realUsers = await this.instanceActorService.realLocalUsersPresent(); + + if (!realUsers && me == null && token == null) { + // 初回セットアップの場合 + if (this.config.setupPassword != null) { + // 初期パスワードが設定されている場合 + if (ps.setupPassword !== this.config.setupPassword) { + // 初期パスワードが違う場合 + throw new ApiError(meta.errors.wrongInitialPassword); + } + } else if (ps.setupPassword != null && ps.setupPassword.trim() !== '') { + // 初期パスワードが設定されていないのに初期パスワードが入力された場合 + throw new ApiError(meta.errors.wrongInitialPassword); + } + } else { + if (token && !meta.softPermissions.every(p => token.permission.includes(p))) { + // Tokens have scoped permissions which may be *less* than the user's official role, so we need to check. + throw new ApiError(meta.errors.noPermission); + } + + if (me && !await this.roleService.isAdministrator(me)) { + // Only administrators (including root) can create users. + throw new ApiError(meta.errors.noAdmin); + } + + // Anonymous access is only allowed for initial instance setup (this check may be redundant) + if (!me && realUsers) { + throw new ApiError(meta.errors.noCredential); + } + } const { account, secret } = await this.signupService.signup({ username: ps.username, @@ -96,21 +148,4 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- return res; }); } - - private async ensurePermissions(me: MiUser | null, token: MiAccessToken | null): Promise<void> { - // Tokens have scoped permissions which may be *less* than the user's official role, so we need to check. - if (token && !meta.softPermissions.every(p => token.permission.includes(p))) { - throw new ApiError(meta.errors.noPermission); - } - - // Only administrators (including root) can create users. - if (me && !await this.roleService.isAdministrator(me)) { - throw new ApiError(meta.errors.noAdmin); - } - - // Anonymous access is only allowed for initial instance setup. - if (!me && await this.instanceActorService.realLocalUsersPresent()) { - throw new ApiError(meta.errors.noCredential); - } - } } diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 01dea703a3..ece1984cff 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new Error('cannot delete a root account'); } - await this.deleteAccoountService.deleteAccount(user); + await this.deleteAccoountService.deleteAccount(user, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 2dae1df87d..b8bfda73a4 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -55,7 +55,7 @@ export const paramDef = { properties: { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, - imageUrl: { type: 'string', nullable: true, minLength: 1 }, + imageUrl: { type: 'string', nullable: true, minLength: 0 }, icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, forExistingUsers: { type: 'boolean', default: false }, @@ -76,7 +76,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- updatedAt: null, title: ps.title, text: ps.text, - imageUrl: ps.imageUrl, + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ + imageUrl: ps.imageUrl || null, icon: ps.icon, display: ps.display, forExistingUsers: ps.forExistingUsers, diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts index fd21309818..87d80cbe80 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['admin'], @@ -13,6 +14,49 @@ export const meta = { requireCredential: true, requireRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, } as const; export const paramDef = { @@ -32,14 +76,25 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( private avatarDecorationService: AvatarDecorationService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - await this.avatarDecorationService.create({ + const created = await this.avatarDecorationService.create({ name: ps.name, description: ps.description, url: ps.url, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, }, me); + + return { + id: created.id, + createdAt: this.idService.parse(created.id).date.toISOString(), + updatedAt: null, + name: created.name, + description: created.description, + url: created.url, + roleIdsThatCanBeUsedThisDecoration: created.roleIdsThatCanBeUsedThisDecoration, + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts index aee90023e1..d785f085ac 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -4,10 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; -import type { MiAnnouncement } from '@/models/Announcement.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts index b6f0f22d60..9065a71f6a 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts @@ -33,13 +33,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private deleteAccountService: DeleteAccountService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneByOrFail({ id: ps.userId }); if (user.isDeleted) { return; } - await this.deleteAccountService.deleteAccount(user); + await this.deleteAccountService.deleteAccount(user, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 3caa0f84a3..071ddbef18 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiEmoji } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -79,25 +79,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); } - let emojiId; - if (ps.id) { - emojiId = ps.id; - const emoji = await this.customEmojiService.getEmojiById(ps.id); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - if (nameNfc && (nameNfc !== emoji.name)) { - const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc); - if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); - } - } else { - if (!nameNfc) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.'); - const emoji = await this.customEmojiService.getEmojiByName(nameNfc); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - emojiId = emoji.id; - } + // JSON schemeのanyOfの型変換がうまくいっていないらしい + const required = { id: ps.id, name: nameNfc } as + | { id: MiEmoji['id']; name?: string } + | { id?: MiEmoji['id']; name: string }; - await this.customEmojiService.update(emojiId, { + const error = await this.customEmojiService.update({ + ...required, driveFile, - name: nameNfc, category: ps.category?.normalize('NFC'), aliases: ps.aliases?.map(a => a.normalize('NFC')), license: ps.license, @@ -105,6 +94,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- localOnly: ps.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, }, me); + + switch (error) { + case null: return; + case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji); + case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); + } + // 網羅性チェック + const mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts new file mode 100644 index 0000000000..3e42c91fed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AbuseUserReportsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: '8763e21b-d9bc-40be-acf6-54c1a6986493', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + reportId: { type: 'string', format: 'misskey:id' }, + }, + required: ['reportId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + private abuseReportService: AbuseReportService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); + } + + await this.abuseReportService.forward(report.id, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 6e368eff43..6495e3b7da 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -81,6 +81,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -189,6 +193,13 @@ export const meta = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, bannedEmailDomains: { type: 'array', optional: true, nullable: false, @@ -368,6 +379,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableStatsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, enableServerMachineStats: { type: 'boolean', optional: false, nullable: false, @@ -614,6 +629,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- turnstileSiteKey: instance.turnstileSiteKey, enableFC: instance.enableFC, fcSiteKey: instance.fcSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -642,6 +658,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- mediaSilencedHosts: instance.mediaSilencedHosts, sensitiveWords: instance.sensitiveWords, prohibitedWords: instance.prohibitedWords, + prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser, preservedUsernames: instance.preservedUsernames, bubbleInstances: instance.bubbleInstances, hcaptchaSecretKey: instance.hcaptchaSecretKey, @@ -688,6 +705,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- truemailAuthKey: instance.truemailAuthKey, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, + enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, enableAchievements: instance.enableAchievements, enableIdenticonGeneration: instance.enableIdenticonGeneration, diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index d7f9e4eaa3..e2bd38aac6 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduleNotePostQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -55,6 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) { super(meta, paramDef, async (ps, me) => { const deliverJobCounts = await this.deliverQueue.getJobCounts(); 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 index 9b79100fcf..554d324ff2 100644 --- 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 @@ -32,7 +32,7 @@ export const paramDef = { type: 'object', properties: { reportId: { type: 'string', format: 'misskey:id' }, - forward: { type: 'boolean', default: false }, + resolvedAs: { type: 'string', enum: ['accept', 'reject', null], nullable: true }, }, required: ['reportId'], } as const; @@ -50,7 +50,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.noSuchAbuseReport); } - await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me); + await this.abuseReportService.resolve([{ reportId: report.id, resolvedAs: ps.resolvedAs ?? null }], me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 5f16519403..cc65ed2cf0 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -72,13 +72,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- break; } case 'moderator': { - const moderatorIds = await this.roleService.getModeratorIds(false); + const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false }); if (moderatorIds.length === 0) return []; query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); break; } case 'adminOrModerator': { - const adminOrModeratorIds = await this.roleService.getModeratorIds(); + const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true }); if (adminOrModeratorIds.length === 0) return []; query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); break; diff --git a/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts new file mode 100644 index 0000000000..73d4b843f0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AbuseUserReportsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: '15f51cf5-46d1-4b1d-a618-b35bcbed0662', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + reportId: { type: 'string', format: 'misskey:id' }, + moderationNote: { type: 'string' }, + }, + required: ['reportId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + private abuseReportService: AbuseReportService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); + } + + await this.abuseReportService.update(report.id, { + moderationNote: ps.moderationNote, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 98760bbcc3..72f428d85f 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -46,6 +46,11 @@ export const paramDef = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -84,6 +89,7 @@ export const paramDef = { enableFC: { type: 'boolean' }, fcSiteKey: { type: 'string', nullable: true }, fcSecretKey: { type: 'string', nullable: true }, + enableTestcaptcha: { type: 'boolean' }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -140,6 +146,7 @@ export const paramDef = { truemailAuthKey: { type: 'string', nullable: true }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + enableStatsForFederatedInstances: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' }, enableAchievements: { type: 'boolean' }, enableIdenticonGeneration: { type: 'boolean' }, @@ -230,6 +237,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (Array.isArray(ps.prohibitedWords)) { set.prohibitedWords = ps.prohibitedWords.filter(Boolean); } + if (Array.isArray(ps.prohibitedWordsForNameOfUser)) { + set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean); + } if (Array.isArray(ps.silencedHosts)) { let lastValue = ''; set.silencedHosts = ps.silencedHosts.sort().filter((h) => { @@ -390,6 +400,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.enableFC = ps.enableFC; } + if (ps.enableTestcaptcha !== undefined) { + set.enableTestcaptcha = ps.enableTestcaptcha; + } + if (ps.fcSiteKey !== undefined) { set.fcSiteKey = ps.fcSiteKey; } @@ -610,6 +624,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.enableStatsForFederatedInstances !== undefined) { + set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances; + } + if (ps.enableServerMachineStats !== undefined) { set.enableServerMachineStats = ps.enableServerMachineStats; } @@ -709,7 +727,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } if (Array.isArray(ps.federationHosts)) { - set.blockedHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); + set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } const before = await this.metaService.fetch(true); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 4232bc6e39..616a77e337 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -137,6 +137,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (local != null) return local; } + // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない return await this.mergePack( me, isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts index f6c0c045df..dcdcf46d0b 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts index 4c5c0d5d20..28c64229e7 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index 8210ec8fe7..69ff3c5d7a 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index 56a5dbea31..bd870cc3d9 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index 7f79e1356d..765bf024ee 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index b3660b558b..ecac436311 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts index 716c41f385..98ec40ade2 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts index b67b5ca338..cb3dd36bab 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts index e5587cab86..0742a21210 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/pv.ts b/packages/backend/src/server/api/endpoints/charts/user/pv.ts index cbae3a21c1..a220381b00 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/pv.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/pv.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts index d734240742..3bb33622c2 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index 6e1a8ebd4f..b5452517ab 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -17,10 +17,11 @@ export const meta = { allowGet: true, cacheSec: 60 * 60, - // 10 calls per 5 seconds + // Burst up to 100, then 2/sec average limit: { - duration: 1000 * 5, - max: 10, + type: 'bucket', + size: 100, + dripRate: 500, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index d5a14ca8f4..3cc7f89ab9 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -56,16 +56,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.find({ - where: { - host: IsNull(), - }, - order: { - category: 'ASC', - name: 'ASC', - }, - }); - + const emojis = await this.emojisRepository.createQueryBuilder() + .where('host IS NULL') + .orderBy('LOWER(category)', 'ASC') + .orderBy('LOWER(name)', 'ASC') + .getMany(); return { emojis: await this.emojiEntityService.packSimpleMany(emojis), }; diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index 7629cd7a67..a1dbb26431 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -29,10 +29,13 @@ export const meta = { }, }, - // 5 calls per second + // 1000 max @ 1/10ms drip = 10/sec average. + // Large bucket is ok because this is a fairly lightweight endpoint. limit: { - duration: 1000, - max: 5, + type: 'bucket', + + size: 1000, + dripRate: 10, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts index 2e8cbffe2a..ad1f35055a 100644 --- a/packages/backend/src/server/api/endpoints/flash/featured.ts +++ b/packages/backend/src/server/api/endpoints/flash/featured.ts @@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; export const meta = { tags: ['flash'], @@ -33,26 +34,25 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + offset: { type: 'integer', minimum: 0, default: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, required: [], } as const; @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - + private flashService: FlashService, private flashEntityService: FlashEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.flashsRepository.createQueryBuilder('flash') - .andWhere('flash.likedCount > 0') - .orderBy('flash.likedCount', 'DESC'); - - const flashs = await query.limit(10).getMany(); - - return await this.flashEntityService.packMany(flashs, me); + const result = await this.flashService.featured({ + offset: ps.offset, + limit: ps.limit, + }); + return await this.flashEntityService.packMany(result, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 8994c3fff6..09c06a108d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import RE2 from 're2'; import * as mfm from '@transfem-org/sfm-js'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; @@ -11,7 +10,7 @@ import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; -import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; +import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { birthdaySchema, listenbrainzSchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; @@ -22,6 +21,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js'; @@ -126,6 +126,13 @@ export const meta = { code: 'RESTRICTED_BY_ROLE', id: '8feff0ba-5ab5-585b-31f4-4df816663fad', }, + + nameContainsProhibitedWords: { + message: 'Your new name contains prohibited words.', + code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS', + id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', + httpStatusCode: 422, + }, }, res: { @@ -187,6 +194,10 @@ export const paramDef = { noCrawle: { type: 'boolean' }, preventAiLearning: { type: 'boolean' }, noindex: { type: 'boolean' }, + requireSigninToViewContents: { type: 'boolean' }, + makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true }, + makeNotesHiddenBefore: { type: 'integer', nullable: true }, + enableRss: { type: 'boolean' }, isBot: { type: 'boolean' }, isCat: { type: 'boolean' }, speakAsCat: { type: 'boolean' }, @@ -241,6 +252,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private instanceMeta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -265,6 +279,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private cacheService: CacheService, private httpRequestService: HttpRequestService, private avatarDecorationService: AvatarDecorationService, + private utilityService: UtilityService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -309,7 +324,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (!regexp) throw new ApiError(meta.errors.invalidRegexp); try { - new RE2(regexp[1], regexp[2]); + new RegExp(regexp[1], regexp[2]); } catch (err) { throw new ApiError(meta.errors.invalidRegexp); } @@ -337,10 +352,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; if (typeof ps.noindex === 'boolean') updates.noindex = ps.noindex; + if (typeof ps.enableRss === 'boolean') updates.enableRss = ps.enableRss; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.requireSigninToViewContents === 'boolean') updates.requireSigninToViewContents = ps.requireSigninToViewContents; + if ((typeof ps.makeNotesFollowersOnlyBefore === 'number') || (ps.makeNotesFollowersOnlyBefore === null)) updates.makeNotesFollowersOnlyBefore = ps.makeNotesFollowersOnlyBefore; + if ((typeof ps.makeNotesHiddenBefore === 'number') || (ps.makeNotesHiddenBefore === null)) updates.makeNotesHiddenBefore = ps.makeNotesHiddenBefore; if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.speakAsCat === 'boolean') updates.speakAsCat = ps.speakAsCat; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; @@ -483,8 +502,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const newName = updates.name === undefined ? user.name : updates.name; const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; + const newFollowedMessage = profileUpdates.followedMessage === undefined ? profile.followedMessage : profileUpdates.followedMessage; if (newName != null) { + let hasProhibitedWords = false; + if (!await this.roleService.isModerator(user)) { + hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser); + } + if (hasProhibitedWords) { + throw new ApiError(meta.errors.nameContainsProhibitedWords); + } + const tokens = mfm.parseSimple(newName); emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); } @@ -504,6 +532,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ]); } + if (newFollowedMessage != null) { + const tokens = mfm.parse(newFollowedMessage); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); + } + updates.emojis = emojis; updates.tags = tags; @@ -539,7 +572,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } // フォロワーにUpdateを配信 - this.accountUpdateService.publishToFollowers(user.id); + if (this.userNeedsPublishing(user, updates) || this.profileNeedsPublishing(profile, updatedProfile)) { + this.accountUpdateService.publishToFollowers(user.id); + } const urls = updatedProfile.fields.filter(x => x.value.startsWith('https://')); for (const url of urls) { @@ -581,4 +616,58 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- // なにもしない } } + + // these two methods need to be kept in sync with + // `ApRendererService.renderPerson` + private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean { + const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore']; + for (const field of basicFields) { + if ((field in newUser) && oldUser[field] !== newUser[field]) { + return true; + } + } + + const arrayFields: (keyof MiUser)[] = ['emojis', 'tags']; + for (const arrayField of arrayFields) { + if ((arrayField in newUser) !== (arrayField in oldUser)) { + return true; + } + + const oldArray = oldUser[arrayField] ?? []; + const newArray = newUser[arrayField] ?? []; + if (!Array.isArray(oldArray) || !Array.isArray(newArray)) { + return true; + } + if (oldArray.join('\0') !== newArray.join('\0')) { + return true; + } + } + return false; + } + + private profileNeedsPublishing(oldProfile: MiUserProfile, newProfile: Partial<MiUserProfile>): boolean { + const basicFields: (keyof MiUserProfile)[] = ['description', 'followedMessage', 'birthday', 'location', 'listenbrainz']; + for (const field of basicFields) { + if ((field in newProfile) && oldProfile[field] !== newProfile[field]) { + return true; + } + } + + const arrayFields: (keyof MiUserProfile)[] = ['fields']; + for (const arrayField of arrayFields) { + if ((arrayField in newProfile) !== (arrayField in oldProfile)) { + return true; + } + + const oldArray = oldProfile[arrayField] ?? []; + const newArray = newProfile[arrayField] ?? []; + if (!Array.isArray(oldArray) || !Array.isArray(newArray)) { + return true; + } + if (oldArray.join('\0') !== newArray.join('\0')) { + return true; + } + } + return false; + } } diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 0d9eec463b..228793fbf6 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -109,6 +109,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- sub.andWhere('latest.is_quote = false'); } + // Select the appropriate collection of users + if (ps.list === 'followers') { + addFollower(sub); + } else if (ps.list === 'following') { + addFollowee(sub); + } else { + addMutual(sub); + } + return sub; }, 'latest', @@ -124,15 +133,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .leftJoinAndSelect('note.channel', 'channel') ; - // Select the appropriate collection of users - if (ps.list === 'followers') { - addFollower(query); - } else if (ps.list === 'following') { - addFollowee(query); - } else { - addMutual(query); - } - // Limit to files, if requested if (ps.filesOnly) { query.andWhere('note."fileIds" != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts new file mode 100644 index 0000000000..c6032fbdae --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts @@ -0,0 +1,372 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { In } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import moment from 'moment'; +import { isPureRenote } from '@/misc/is-renote.js'; +import type { MiUser } from '@/models/User.js'; +import type { + UsersRepository, + NotesRepository, + BlockingsRepository, + DriveFilesRepository, + ChannelsRepository, + NoteScheduleRepository, +} from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiChannel } from '@/models/Channel.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { MiScheduleNoteType } from '@/models/NoteSchedule.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: 'write:notes-schedule', + + errors: { + scheduleNoteMax: { + message: 'Schedule note max.', + code: 'SCHEDULE_NOTE_MAX', + id: '168707c3-e7da-4031-989e-f42aa3a274b2', + }, + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4', + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', + }, + + cannotRenoteDueToVisibility: { + message: 'You can not Renote due to target visibility.', + code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY', + id: 'be9529e9-fe72-4de0-ae43-0b363c4938af', + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c', + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + + cannotCreateAlreadyExpiredSchedule: { + message: 'Schedule is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE', + id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07', + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb', + }, + noSuchSchedule: { + message: 'No such schedule.', + code: 'NO_SUCH_SCHEDULE', + id: '44dee229-8da1-4a61-856d-e3a4bbc12032', + }, + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', + }, + + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibleUserIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + noExtractMentions: { type: 'boolean', default: false }, + noExtractHashtags: { type: 'boolean', default: false }, + noExtractEmojis: { type: 'boolean', default: false }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + // See https://github.com/misskey-dev/misskey/pull/10082 + text: { + type: 'string', + minLength: 1, + nullable: true, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + scheduleNote: { + type: 'object', + nullable: false, + properties: { + scheduledAt: { type: 'integer', nullable: false }, + }, + }, + }, + // (re)note with text, files and poll are optional + anyOf: [ + { required: ['text'] }, + { required: ['renoteId'] }, + { required: ['fileIds'] }, + { required: ['mediaIds'] }, + { required: ['poll'] }, + ], + required: ['scheduleNote'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private queueService: QueueService, + private roleService: RoleService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id }); + const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax; + if (scheduleNoteCount >= scheduleNoteMax) { + throw new ApiError(meta.errors.scheduleNoteMax); + } + let visibleUsers: MiUser[] = []; + if (ps.visibleUserIds) { + visibleUsers = await this.usersRepository.findBy({ + id: In(ps.visibleUserIds), + }); + } + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + let renote: MiNote | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (isPureRenote(renote)) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== me.id) { + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + if (renote.visibility === 'followers' && renote.userId !== me.id) { + // 他人のfollowers noteはreject + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + } else if (renote.visibility === 'specified') { + // specified / direct noteはreject + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + } + } + + let reply: MiNote | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } else if (isPureRenote(reply)) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== me.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + let scheduleNote_scheduledAt = Date.now(); + if (typeof ps.scheduleNote.scheduledAt === 'number') { + scheduleNote_scheduledAt = moment.utc(ps.scheduleNote.scheduledAt).local().valueOf(); + } + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < scheduleNote_scheduledAt) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = scheduleNote_scheduledAt + ps.poll.expiredAfter; + } + } + if (typeof ps.scheduleNote.scheduledAt === 'number') { + if (moment.utc(ps.scheduleNote.scheduledAt).local().valueOf() < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); + } + } else { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); + } + const note: MiScheduleNoteType = { + files: files.map(f => f.id), + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt).toISOString() : null, + } : undefined, + text: ps.text ?? undefined, + reply: reply?.id, + renote: renote?.id, + cw: ps.cw, + localOnly: false, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUsers, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }; + + if (ps.scheduleNote.scheduledAt) { + me.token = null; + const noteId = this.idService.gen(new Date().getTime()); + const schedNoteLocalTime = moment.utc(ps.scheduleNote.scheduledAt).local().valueOf(); + await this.noteScheduleRepository.insert({ + id: noteId, + note: note, + userId: me.id, + scheduledAt: new Date(schedNoteLocalTime), + }); + + const delay = new Date(schedNoteLocalTime).getTime() - Date.now(); + await this.queueService.ScheduleNotePostQueue.add(String(delay), { + scheduleNoteId: noteId, + }, { + delay, + removeOnComplete: true, + jobId: `schedNote:${noteId}`, + }); + } + + return ''; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts new file mode 100644 index 0000000000..628fd89926 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NoteScheduleRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'write:notes-schedule', + + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a58056ba-8ba1-4323-8ebf-e0b585bc244f', + }, + permissionDenied: { + message: 'Permission denied.', + code: 'PERMISSION_DENIED', + id: 'c0da2fed-8f61-4c47-a41d-431992607b5c', + httpStatusCode: 403, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.noteScheduleRepository.findOneBy({ id: ps.noteId }); + if (note === null) { + throw new ApiError(meta.errors.noSuchNote); + } + if (note.userId !== me.id) { + throw new ApiError(meta.errors.permissionDenied); + } + await this.noteScheduleRepository.delete({ id: ps.noteId }); + await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${ps.noteId}`); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts new file mode 100644 index 0000000000..4895733d4e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { noteVisibilities } from '@/types.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'read:notes-schedule', + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { type: 'string', format: 'misskey:id', optional: false, nullable: false }, + note: { + type: 'object', + optional: false, nullable: false, + properties: { + createdAt: { type: 'string', optional: false, nullable: false }, + text: { type: 'string', optional: true, nullable: false }, + cw: { type: 'string', optional: true, nullable: true }, + fileIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', format: 'misskey:id', optional: false, nullable: false } }, + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], optional: false, nullable: false }, + visibleUsers: { + type: 'array', optional: false, nullable: false, items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'User', + }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + isSchedule: { type: 'boolean', optional: false, nullable: false }, + }, + }, + userId: { type: 'string', optional: false, nullable: false }, + scheduledAt: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.noteScheduleRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.userId = :userId', { userId: me.id }); + const scheduleNotes = await query.limit(ps.limit).getMany(); + const user = await this.userEntityService.pack(me, me); + const scheduleNotesPack: { + id: string; + note: { + text?: string; + cw?: string|null; + fileIds: string[]; + visibility: typeof noteVisibilities[number]; + visibleUsers: Packed<'UserLite'>[]; + reactionAcceptance: MiNote['reactionAcceptance']; + user: Packed<'User'>; + createdAt: string; + isSchedule: boolean; + }; + userId: string; + scheduledAt: string; + }[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => { + return { + ...item, + scheduledAt: item.scheduledAt.toISOString(), + note: { + ...item.note, + text: item.note.text ?? '', + user: user, + visibility: item.note.visibility ?? 'public', + reactionAcceptance: item.note.reactionAcceptance ?? null, + visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [], + fileIds: item.note.files ? item.note.files : [], + files: await this.driveFileEntityService.packManyByIds(item.note.files), + createdAt: item.scheduledAt.toISOString(), + isSchedule: true, + id: item.id, + }, + }; + })); + + return scheduleNotesPack; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 49c51cb33c..f0c9db38b4 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -28,6 +28,12 @@ export const meta = { code: 'NO_SUCH_NOTE', id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', }, + + signinRequired: { + message: 'Signin required.', + code: 'SIGNIN_REQUIRED', + id: '8e75455b-738c-471d-9f80-62693f33372e', + }, }, // 2 calls per second @@ -56,7 +62,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = await this.notesRepository.createQueryBuilder('note') - .where('note.id = :noteId', { noteId: ps.noteId }); + .where('note.id = :noteId', { noteId: ps.noteId }) + .innerJoinAndSelect('note.user', 'user'); this.queryService.generateVisibilityQuery(query, me); if (me) { @@ -69,6 +76,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.noSuchNote); } + if (note.user!.requireSigninToViewContents && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + return await this.noteEntityService.pack(note, me, { detail: true, }); diff --git a/packages/backend/src/server/api/endpoints/notes/versions.ts b/packages/backend/src/server/api/endpoints/notes/versions.ts index 343417f0e2..9b98d19fb1 100644 --- a/packages/backend/src/server/api/endpoints/notes/versions.ts +++ b/packages/backend/src/server/api/endpoints/notes/versions.ts @@ -27,6 +27,12 @@ export const meta = { code: 'NO_SUCH_NOTE', id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', }, + + signinRequired: { + message: 'Signin required.', + code: 'SIGNIN_REQUIRED', + id: '8e75455b-738c-471d-9f80-62693f33372e', + }, }, // 10 calls per 5 seconds @@ -55,10 +61,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = await this.notesRepository.createQueryBuilder('note') - .select('note.id') - .where('note.id = :noteId', { noteId: ps.noteId }); + .where('note.id = :noteId', { noteId: ps.noteId }) + .innerJoinAndSelect('note.user', 'user'); this.queryService.generateVisibilityQuery(query, me); + if (me) { + this.queryService.generateBlockedUserQuery(query, me); + } const note = await query.getOne(); @@ -66,6 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.noSuchNote); } + if (note.user!.requireSigninToViewContents && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + const edits = await this.getterService.getEdits(ps.noteId).catch(err => { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 92d8032fa6..6416e43ff1 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -43,6 +43,12 @@ export const meta = { code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222', }, + + signinRequired: { + message: 'Signin required.', + code: 'SIGNIN_REQUIRED', + id: 'd1588a9e-4b4d-4c07-807f-16f1486577a2', + }, }, // 5 calls per second diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index f811020645..81c0c526f0 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -72,10 +72,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.cannotReportYourself); } - if (await this.roleService.isAdministrator(targetUser)) { - throw new ApiError(meta.errors.cannotReportAdmin); - } - await this.abuseReportService.report([{ targetUserId: targetUser.id, targetUserHost: targetUser.host, diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index fb5954fee0..82ee0f47d7 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -183,7 +183,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { }, ...(endpoint.meta.limit ? { '429': { - description: 'To many requests', + description: 'Too many requests', content: { 'application/json': { schema: { |