From aee984813d90ac9faa7824f33985c61102c9eba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Wed, 25 Sep 2024 19:27:50 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(backend):=20embed=E3=81=AE=E5=8B=95?= =?UTF-8?q?=E4=BD=9C=E3=81=AB=E5=BF=85=E8=A6=81=E3=81=AA=E5=80=A4=E3=82=92?= =?UTF-8?q?=E5=BE=A9=E6=B4=BB=E3=81=95=E3=81=9B=E3=82=8B=20(#14633)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/server/web/views/base-embed.pug | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug index 2bab20a36c..baa0909676 100644 --- a/packages/backend/src/server/web/views/base-embed.pug +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -13,6 +13,8 @@ html(class='embed') meta(name='referrer' content='origin') meta(name='theme-color' content= themeColor || '#86b300') meta(name='theme-color-orig' content= themeColor || '#86b300') + meta(property='og:site_name' content= instanceName || 'Misskey') + meta(property='instance_url' content= instanceUrl) meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') link(rel='icon' href= icon || '/favicon.ico') From fde94f638b3adc00cdd8668d8c778f2532056162 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:18:23 +0900 Subject: [PATCH 2/7] Update about-misskey.vue --- packages/frontend/src/pages/about-misskey.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 960df59485..b481fd590c 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -371,6 +371,7 @@ const patrons = [ '塩キャベツ', 'はとぽぷさん', '100の人 (エスパー・イーシア)', + 'ケモナーのケシン', ]; const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure')); From d8dd1683c9254c18e3e561155c64da5bba2231d5 Mon Sep 17 00:00:00 2001 From: Yuri Lee Date: Thu, 26 Sep 2024 08:25:33 +0900 Subject: [PATCH 3/7] Add Sign in with passkey Button (#14577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sign in with passkey (PoC) * 💄 Added "Login with Passkey" Button * refactor: Improve error response when WebAuthn challenge fails * signinResponse should be placed under the SigninWithPasskeyResponse object. * Frontend fix * Fix: Rate limiting key for passkey signin Use specific rate limiting key: 'signin-with-passkey' for passkey sign-in API to avoid collisions with signin rate-limit. * Refactor: enhance Passkey sign-in flow and error handling - Increased the rate limit for Passkey sign-in attempts to accommodate the two API calls needed per sign-in. - Improved error messages and handling in both the `WebAuthnService` and the `SigninWithPasskeyApiService`, providing more context and better usability. - Updated error messages to provide more specific and helpful details to the user. These changes aim to enhance the Passkey sign-in experience by providing more robust error handling, improving security by limiting API calls, and delivering a more user-friendly interface. * Refactor: Streamline 2FA flow and remove redundant Passkey button. - Separate the flow of 1FA and 2FA. - Remove duplicate passkey buttons * Fix: Add error messages to MkSignin * chore: Hide passkey button if the entered user does not use passkey login * Update CHANGELOG.md * Refactor: Rename functions and Add comments * Update locales/ja-JP.yml Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * Fix: Update translation - update index.d.ts - update ko-KR.yml, en-US.yml - Fix: Reflect Changed i18n key on MkSignin --------- Co-authored-by: Squarecat-meow Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + locales/index.d.ts | 16 ++ locales/ja-JP.yml | 4 + packages/backend/src/core/WebAuthnService.ts | 80 ++++++++ packages/backend/src/server/ServerModule.ts | 2 + .../src/server/api/ApiServerService.ts | 9 + .../server/api/SigninWithPasskeyApiService.ts | 173 ++++++++++++++++++ packages/frontend/src/components/MkSignin.vue | 95 +++++++++- .../src/components/MkSigninDialog.vue | 2 +- packages/misskey-js/etc/misskey-js.api.md | 19 ++ packages/misskey-js/src/api.types.ts | 6 + packages/misskey-js/src/entities.ts | 11 ++ 12 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/server/api/SigninWithPasskeyApiService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf4f66793..5a75b9d933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680) - Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように +- Feat: パスキーでログインボタンを実装 (#14574) ### Client - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 diff --git a/locales/index.d.ts b/locales/index.d.ts index f379fe7c40..a7a02bdf61 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5116,6 +5116,22 @@ export interface Locale extends ILocale { * {n}件の変更があります */ "thereAreNChanges": ParameterizedString<"n">; + /** + * パスキーでログイン + */ + "signinWithPasskey": string; + /** + * 登録されていないパスキーです。 + */ + "unknownWebAuthnKey": string; + /** + * パスキーの検証に失敗しました。 + */ + "passkeyVerificationFailed": string; + /** + * パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。 + */ + "passkeyVerificationSucceededButPasswordlessLoginDisabled": string; "_delivery": { /** * 配信状態 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 25af266c0b..ad81b1f497 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1275,6 +1275,10 @@ performance: "パフォーマンス" modified: "変更あり" discard: "破棄" thereAreNChanges: "{n}件の変更があります" +signinWithPasskey: "パスキーでログイン" +unknownWebAuthnKey: "登録されていないパスキーです。" +passkeyVerificationFailed: "パスキーの検証に失敗しました。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" _delivery: status: "配信状態" diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index a40c6ff1c9..75ab0a207c 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -164,6 +164,86 @@ export class WebAuthnService { return authenticationOptions; } + /** + * Initiate Passkey Auth (Without specifying user) + * @returns authenticationOptions + */ + @bindThis + public async initiateSignInWithPasskeyAuthentication(context: string): Promise { + const relyingParty = await this.getRelyingParty(); + + const authenticationOptions = await generateAuthenticationOptions({ + rpID: relyingParty.rpId, + userVerification: 'preferred', + }); + + await this.redisClient.setex(`webauthn:challenge:${context}`, 90, authenticationOptions.challenge); + + return authenticationOptions; + } + + /** + * Verify Webauthn AuthenticationCredential + * @throws IdentifiableError + * @returns If the challenge is successful, return the user ID. Otherwise, return null. + */ + @bindThis + public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise { + const challenge = await this.redisClient.get(`webauthn:challenge:${context}`); + + if (!challenge) { + throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`); + } + + await this.redisClient.del(`webauthn:challenge:${context}`); + + const key = await this.userSecurityKeysRepository.findOneBy({ + id: response.id, + }); + + if (!key) { + throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'Unknown Webauthn key'); + } + + const relyingParty = await this.getRelyingParty(); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: response, + expectedChallenge: challenge, + expectedOrigin: relyingParty.origin, + expectedRPID: relyingParty.rpId, + authenticator: { + credentialID: key.id, + credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), + counter: key.counter, + transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, + }, + requireUserVerification: true, + }); + } catch (error) { + throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`); + } + + const { verified, authenticationInfo } = verification; + + if (!verified) { + return null; + } + + await this.userSecurityKeysRepository.update({ + id: response.id, + }, { + lastUsed: new Date(), + counter: authenticationInfo.newCounter, + credentialDeviceType: authenticationInfo.credentialDeviceType, + credentialBackedUp: authenticationInfo.credentialBackedUp, + }); + + return key.userId; + } + @bindThis public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise { const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`); diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 12d5061985..3ab0b815f2 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -46,6 +46,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ imports: [ @@ -71,6 +72,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js AuthenticateService, RateLimiterService, SigninApiService, + SigninWithPasskeyApiService, SigninService, SignupApiService, StreamingApiServerService, diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 13cbdfc3be..709a044601 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -8,6 +8,7 @@ import cors from '@fastify/cors'; import multipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; import { ModuleRef } from '@nestjs/core'; +import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { Config } from '@/config.js'; import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -17,6 +18,7 @@ import endpoints from './endpoints.js'; import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; +import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() @@ -37,6 +39,7 @@ export class ApiServerService { private apiCallService: ApiCallService, private signupApiService: SignupApiService, private signinApiService: SigninApiService, + private signinWithPasskeyApiService: SigninWithPasskeyApiService, ) { //this.createServer = this.createServer.bind(this); } @@ -131,6 +134,12 @@ export class ApiServerService { }; }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); + fastify.post<{ + Body: { + credential?: AuthenticationResponseJSON; + }; + }>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply)); + fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply)); fastify.get('/v1/instance/peers', async (request, reply) => { diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts new file mode 100644 index 0000000000..9ba23c54e2 --- /dev/null +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { randomUUID } from 'crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + SigninsRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; +import { IdService } from '@/core/IdService.js'; +import { bindThis } from '@/decorators.js'; +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 { SigninService } from './SigninService.js'; +import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +@Injectable() +export class SigninWithPasskeyApiService { + private logger: Logger; + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private idService: IdService, + private rateLimiterService: RateLimiterService, + private signinService: SigninService, + private webAuthnService: WebAuthnService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('PasskeyAuth'); + } + + @bindThis + public async signin( + request: FastifyRequest<{ + Body: { + credential?: AuthenticationResponseJSON; + context?: string; + }; + }>, + reply: FastifyReply, + ) { + reply.header('Access-Control-Allow-Origin', this.config.url); + reply.header('Access-Control-Allow-Credentials', 'true'); + + const body = request.body; + const credential = body['credential']; + + function error(status: number, error: { id: string }) { + reply.code(status); + return { error }; + } + + const fail = async (userId: MiUser['id'], status?: number, failure?: { id: string }) => { + // Append signin history + await this.signinsRepository.insert({ + id: this.idService.gen(), + userId: userId, + ip: request.ip, + headers: request.headers as any, + success: false, + }); + 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) { + reply.code(429); + return { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + } + + // Initiate Passkey Auth challenge with context + if (!credential) { + const context = randomUUID(); + this.logger.info(`Initiate Passkey challenge: context: ${context}`); + const authChallengeOptions = { + option: await this.webAuthnService.initiateSignInWithPasskeyAuthentication(context), + context: context, + }; + reply.code(200); + return authChallengeOptions; + } + + const context = body.context; + if (!context || typeof context !== 'string') { + // If try Authentication without context + return error(400, { + id: '1658cc2e-4495-461f-aee4-d403cdf073c1', + }); + } + + this.logger.debug(`Try Sign-in with Passkey: context: ${context}`); + + let authorizedUserId: MiUser['id'] | null; + try { + authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential); + } catch (err) { + this.logger.warn(`Passkey challenge Verify error! : ${err}`); + const errorId = (err as IdentifiableError).id; + return error(403, { + id: errorId, + }); + } + + if (!authorizedUserId) { + return error(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } + + // Fetch user + const user = await this.usersRepository.findOneBy({ + id: authorizedUserId, + host: IsNull(), + }) as MiLocalUser | null; + + if (user == null) { + return error(403, { + id: '652f899f-66d4-490e-993e-6606c8ec04c3', + }); + } + + if (user.isSuspended) { + return error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + // Authentication was successful, but passwordless login is not enabled + if (!profile.usePasswordLessLogin) { + return await fail(user.id, 403, { + id: '2d84773e-f7b7-4d0b-8f72-bb69b584c912', + }); + } + + const signinResponse = this.signinService.signin(request, reply, user); + return { + signinResponse: signinResponse, + }; + } +} diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 231a6dfcf5..7942a84d66 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - @@ -57,6 +53,16 @@ SPDX-License-Identifier: AGPL-3.0-only {{ signing ? i18n.ts.loggingIn : i18n.ts.login }}
+
+

{{ i18n.ts.or }}

+
+
+ + + {{ signing ? i18n.ts.loggingIn : i18n.ts.signinWithPasskey }} + +

{{ i18n.ts.useSecurityKey }}

+
@@ -66,13 +72,15 @@ import { defineAsyncComponent, ref } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; +import { SigninWithPasskeyResponse } from 'misskey-js/entities.js'; import { query, extractDomain } from '@@/js/url.js'; +import { host as configHost } from '@@/js/config.js'; +import MkDivider from './MkDivider.vue'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { host as configHost } from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; @@ -80,6 +88,7 @@ import { i18n } from '@/i18n.js'; const signing = ref(false); const user = ref(null); +const usePasswordLessLogin = ref(true); const username = ref(''); const password = ref(''); const token = ref(''); @@ -88,6 +97,7 @@ const totpLogin = ref(false); const isBackupCode = ref(false); const queryingKey = ref(false); let credentialRequest: CredentialRequestOptions | null = null; +const passkey_context = ref(''); const emit = defineEmits<{ (ev: 'login', v: any): void; @@ -110,8 +120,10 @@ function onUsernameChange(): void { username: username.value, }).then(userResponse => { user.value = userResponse; + usePasswordLessLogin.value = userResponse.usePasswordLessLogin; }, () => { user.value = null; + usePasswordLessLogin.value = true; }); } @@ -121,7 +133,7 @@ function onLogin(res: any): Promise | void { } } -async function queryKey(): Promise { +async function query2FaKey(): Promise { if (credentialRequest == null) return; queryingKey.value = true; await webAuthnRequest(credentialRequest) @@ -150,6 +162,47 @@ async function queryKey(): Promise { }); } +function onPasskeyLogin(): void { + signing.value = true; + if (webAuthnSupported()) { + misskeyApi('signin-with-passkey', {}) + .then((res: SigninWithPasskeyResponse) => { + totpLogin.value = false; + signing.value = false; + queryingKey.value = true; + passkey_context.value = res.context ?? ''; + credentialRequest = parseRequestOptionsFromJSON({ + publicKey: res.option, + }); + }) + .then(() => queryPasskey()) + .catch(loginFailed); + } +} + +async function queryPasskey(): Promise { + if (credentialRequest == null) return; + queryingKey.value = true; + console.log('Waiting passkey auth...'); + await webAuthnRequest(credentialRequest) + .catch((err) => { + console.warn('Passkey Auth fail!: ', err); + queryingKey.value = false; + return Promise.reject(null); + }).then(credential => { + credentialRequest = null; + queryingKey.value = false; + signing.value = true; + return misskeyApi('signin-with-passkey', { + credential: credential.toJSON(), + context: passkey_context.value, + }); + }).then((res: SigninWithPasskeyResponse) => { + emit('login', res.signinResponse); + return onLogin(res.signinResponse); + }); +} + function onSubmit(): void { signing.value = true; if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { @@ -164,7 +217,7 @@ function onSubmit(): void { publicKey: res, }); }) - .then(() => queryKey()) + .then(() => query2FaKey()) .catch(loginFailed); } else { totpLogin.value = true; @@ -212,6 +265,30 @@ function loginFailed(err: any): void { }); break; } + case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.unknownWebAuthnKey, + }); + break; + } + case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.passkeyVerificationFailed, + }); + break; + } + case '2d84773e-f7b7-4d0b-8f72-bb69b584c912': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.passkeyVerificationSucceededButPasswordlessLoginDisabled, + }); + break; + } default: { console.error(err); os.alert({ diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 524c62b4d3..d48780e9de 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 9ffd0aa025..a5f12b41f4 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1160,6 +1160,10 @@ export type Endpoints = Overwrite; res: AdminRolesCreateResponse; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 08d3dc5c6d..64ed90cbb1 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -271,6 +271,17 @@ export type SigninRequest = { token?: string; }; +export type SigninWithPasskeyRequest = { + credential?: object; + context?: string; +}; + +export type SigninWithPasskeyResponse = { + option?: object; + context?: string; + signinResponse?: SigninResponse; +}; + export type SigninResponse = { id: User['id'], i: string, From 4c76ea1fa6f7fce5cd4a50b970cecc1592b54a56 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:26:13 +0900 Subject: [PATCH 4/7] Update CHANGELOG.md --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a75b9d933..630de65567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,15 @@ ## 2024.9.0 ### General +- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 + - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください +- Feat: パスキーでログインボタンを実装 (#14574) - Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445) - Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680) - Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように -- Feat: パスキーでログインボタンを実装 (#14574) ### Client -- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 - - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください - Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように - Enhance: アイコンデコレーション管理画面にプレビューを追加 - Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく From 7134d24c1f25859e7e092f757ecd327469d75a8f Mon Sep 17 00:00:00 2001 From: KOBA789 Date: Thu, 26 Sep 2024 10:25:20 +0900 Subject: [PATCH 5/7] perf(backend): Defer instance metadata update (#14558) * Defer instance metadata update * Fix last new line * Fix typo * Add license notice * Fix syntax * Perform deferred jobs on shutdown * Fix missing async/await * Fix typo :) * Update collapsed-queue.ts --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- .../backend/src/core/NoteCreateService.ts | 27 +++++++-- packages/backend/src/misc/collapsed-queue.ts | 44 +++++++++++++++ .../queue/processors/InboxProcessorService.ts | 55 ++++++++++++++++--- 3 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 packages/backend/src/misc/collapsed-queue.ts diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 18efc9d562..89e3eafa0e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -55,6 +55,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -146,6 +147,7 @@ type Option = { @Injectable() export class NoteCreateService implements OnApplicationShutdown { #shutdownController = new AbortController(); + private updateNotesCountQueue: CollapsedQueue; constructor( @Inject(DI.config) @@ -215,7 +217,9 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, - ) { } + ) { + this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); + } @bindThis public async create(user: { @@ -509,7 +513,7 @@ export class NoteCreateService implements OnApplicationShutdown { // Register host if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); + this.updateNotesCountQueue.enqueue(i.id, 1); if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateNote(i.host, note, true); } @@ -1028,12 +1032,23 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public dispose(): void { - this.#shutdownController.abort(); + private collapseNotesCount(oldValue: number, newValue: number) { + return oldValue + newValue; } @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); + private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) { + await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy); + } + + @bindThis + public async dispose(): Promise { + this.#shutdownController.abort(); + await this.updateNotesCountQueue.performAllNow(); + } + + @bindThis + public async onApplicationShutdown(signal?: string | undefined): Promise { + await this.dispose(); } } diff --git a/packages/backend/src/misc/collapsed-queue.ts b/packages/backend/src/misc/collapsed-queue.ts new file mode 100644 index 0000000000..5bc20a78ae --- /dev/null +++ b/packages/backend/src/misc/collapsed-queue.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type Job = { + value: V; + timer: NodeJS.Timeout; +}; + +// TODO: redis使えるようにする +export class CollapsedQueue { + private jobs: Map> = new Map(); + + constructor( + private timeout: number, + private collapse: (oldValue: V, newValue: V) => V, + private perform: (key: K, value: V) => Promise, + ) {} + + enqueue(key: K, value: V) { + if (this.jobs.has(key)) { + const old = this.jobs.get(key)!; + const merged = this.collapse(old.value, value); + this.jobs.set(key, { ...old, value: merged }); + } else { + const timer = setTimeout(() => { + const job = this.jobs.get(key)!; + this.jobs.delete(key); + this.perform(key, job.value); + }, this.timeout); + this.jobs.set(key, { value, timer }); + } + } + + async performAllNow() { + const entries = [...this.jobs.entries()]; + this.jobs.clear(); + for (const [_key, job] of entries) { + clearTimeout(job.timer); + } + await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value))); + } +} diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 2df37bedf4..68999b5d17 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -4,7 +4,7 @@ */ import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; import type Logger from '@/logger.js'; @@ -25,14 +25,22 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { InboxJobData } from '../types.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { MiNote } from '@/models/Note.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type { InboxJobData } from '../types.js'; + +type UpdateInstanceJob = { + latestRequestReceivedAt: Date, + shouldUnsuspend: boolean, +}; @Injectable() -export class InboxProcessorService { +export class InboxProcessorService implements OnApplicationShutdown { private logger: Logger; + private updateInstanceQueue: CollapsedQueue; constructor( @Inject(DI.meta) @@ -51,6 +59,7 @@ export class InboxProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); + this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis @@ -187,11 +196,9 @@ export class InboxProcessorService { // Update stats this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.federatedInstanceService.update(i.id, { + this.updateInstanceQueue.enqueue(i.id, { latestRequestReceivedAt: new Date(), - isNotResponding: false, - // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる - suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined, + shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); @@ -227,4 +234,36 @@ export class InboxProcessorService { } return 'ok'; } + + @bindThis + public collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) { + const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt + ? newJob.latestRequestReceivedAt + : oldJob.latestRequestReceivedAt; + const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend; + return { + latestRequestReceivedAt, + shouldUnsuspend, + }; + } + + @bindThis + public async performUpdateInstance(id: string, job: UpdateInstanceJob) { + await this.federatedInstanceService.update(id, { + latestRequestReceivedAt: new Date(), + isNotResponding: false, + // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる + suspensionState: job.shouldUnsuspend ? 'none' : undefined, + }); + } + + @bindThis + public async dispose(): Promise { + await this.updateInstanceQueue.performAllNow(); + } + + @bindThis + async onApplicationShutdown(signal?: string) { + await this.dispose(); + } } From 31988db5479c8562739df709bbdd6d3043d61e70 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:35:40 +0900 Subject: [PATCH 6/7] :art: --- packages/frontend/src/components/MkButton.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index aab5b8a4b2..1156b3f2b8 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -229,6 +229,7 @@ function onMousedown(evt: MouseEvent): void { } &.danger { + font-weight: bold; color: #ff2a2a; &.primary { From 89841e4c9a6a1c8e12ffecaa0964d4a0b06c16c5 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:41:48 +0900 Subject: [PATCH 7/7] =?UTF-8?q?enhance(frontend):=20=E7=B5=B5=E6=96=87?= =?UTF-8?q?=E5=AD=97=E3=83=94=E3=83=83=E3=82=AB=E3=83=BC=E3=82=92=E3=83=89?= =?UTF-8?q?=E3=83=AD=E3=83=AF=E3=83=BC=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=81=8B=E8=87=AA=E7=94=B1=E3=81=AB=E8=A8=AD=E5=AE=9A=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ locales/index.d.ts | 4 ++++ locales/ja-JP.yml | 1 + .../frontend/src/components/MkEmojiPickerDialog.vue | 2 +- .../frontend/src/pages/settings/emoji-picker.vue | 13 ++++++++----- .../src/pages/settings/preferences-backups.vue | 2 +- packages/frontend/src/store.ts | 4 ++-- 7 files changed, 20 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 630de65567..086ff8dcc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680) - Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように +- Enhance: 依存関係の更新 +- Enhance: l10nの更新 ### Client - Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように @@ -15,6 +17,7 @@ - Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく - Enhance: ScratchpadにUIインスペクターを追加 - Enhance: Play編集画面の項目の並びを少しリデザイン +- Enhance: 各種メニューをドロワー表示するかどうか設定可能に - Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正 - Fix: コントロールパネル内のAp requests内のチャートの表示がおかしかった問題を修正 - Fix: 月の違う同じ日はセパレータが表示されないのを修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index a7a02bdf61..1250aa4f4d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2056,6 +2056,10 @@ export interface Locale extends ILocale { * メニューのスタイル */ "menuStyle": string; + /** + * スタイル + */ + "style": string; /** * ドロワー */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ad81b1f497..6c5df3e658 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -510,6 +510,7 @@ aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" menuStyle: "メニューのスタイル" +style: "スタイル" drawer: "ドロワー" popup: "ポップアップ" showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 7e1ffbfa9e..21c712b441 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="modal" v-slot="{ type, maxHeight }" :zPriority="'middle'" - :preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'" + :preferType="defaultStore.state.emojiPickerStyle" :hasInteractionWithOtherFocusTrappedEls="true" :transparentBg="true" :manualShowing="manualShowing" diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue index dc3e3ee503..999a73df4c 100644 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ b/packages/frontend/src/pages/settings/emoji-picker.vue @@ -113,10 +113,13 @@ SPDX-License-Identifier: AGPL-3.0-only - - {{ i18n.ts.useDrawerReactionPickerForMobile }} + + - + + + + @@ -128,7 +131,7 @@ import Sortable from 'vuedraggable'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -146,7 +149,7 @@ const pinnedEmojis: Ref = ref(deepClone(defaultStore.state.pinnedEmoji const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale')); const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth')); const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight')); -const emojiPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('emojiPickerUseDrawerForMobile')); +const emojiPickerStyle = computed(defaultStore.makeGetterSetter('emojiPickerStyle')); const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev); const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index f6f3b933c6..8b905885ee 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -87,7 +87,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'emojiPickerScale', 'emojiPickerWidth', 'emojiPickerHeight', - 'emojiPickerUseDrawerForMobile', + 'emojiPickerStyle', 'defaultSideView', 'menuDisplay', 'reportError', diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 5b10a9a387..c8b7aa013f 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -304,9 +304,9 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 2, }, - emojiPickerUseDrawerForMobile: { + emojiPickerStyle: { where: 'device', - default: true, + default: 'auto' as 'auto' | 'popup' | 'drawer', }, recentlyUsedEmojis: { where: 'device',