diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.yml b/.github/ISSUE_TEMPLATE/01_bug-report.yml index fd68e602dd..00da7e9a2a 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01_bug-report.yml @@ -54,7 +54,7 @@ body: * Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4 * Browser: Chrome 113.0.5672.126 * Server URL: misskey.example.com - * Misskey: 2025.x.x + * Misskey: 2026.x.x value: | * Model and OS of the device(s): * Browser: @@ -74,7 +74,7 @@ body: Examples: * Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment - * Misskey: 2025.x.x + * Misskey: 2026.x.x * Node: 20.x.x * PostgreSQL: 18.x.x * Redis: 7.x.x diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b374360c..feda6ebffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,28 @@ -## Unreleased +## 2026.1.0 + +### Note +- `users/following` の `birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。 ### General -- +- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように + (Cherry-picked from https://github.com/MisskeyIO/misskey) + - 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました +- 依存関係の更新 ### Client - Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に - Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように - Enhance: ウィジェットの設定項目のラベルの多言語対応 - Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061 +- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正 +- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正 +- Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正 +- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正 ### Server -- +- Enhance: OAuthのクライアント情報取得(Client Information Discovery)において、IndieWeb Living Standard 11 July 2024で定義されているJSONドキュメント形式に対応しました + - JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります + - 従来の実装(12 February 2022版・HTML Microformat形式)も引き続きサポートされます ## 2025.12.2 diff --git a/COPYING b/COPYING index 7635bfc913..a17c82c002 100644 --- a/COPYING +++ b/COPYING @@ -1,5 +1,5 @@ Unless otherwise stated this repository is -Copyright © 2014-2025 syuilo and contributors +Copyright © 2014-2026 syuilo and contributors And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE. diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a6edca0b98..e1464151e7 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2600,7 +2600,7 @@ _widgets: _userList: chooseList: "リストを選択" clicker: "クリッカー" - birthdayFollowings: "今日誕生日のユーザー" + birthdayFollowings: "もうすぐ誕生日のユーザー" chat: "ダイレクトメッセージ" _widgetOptions: @@ -2639,6 +2639,8 @@ _widgetOptions: shuffle: "表示順をシャッフル" duration: "ティッカーのスクロール速度(秒)" reverse: "逆方向にスクロール" + _birthdayFollowings: + period: "期間" _cw: hide: "隠す" diff --git a/package.json b/package.json index 8022138ea1..cbb1cbfcd2 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2025.12.2", + "version": "2026.1.0-alpha.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@10.25.0", + "packageManager": "pnpm@10.26.2", "workspaces": [ "packages/misskey-js", "packages/i18n", @@ -58,7 +58,7 @@ }, "dependencies": { "cssnano": "7.1.2", - "esbuild": "0.27.1", + "esbuild": "0.27.2", "execa": "9.6.1", "ignore-walk": "8.0.0", "js-yaml": "4.1.1", @@ -67,19 +67,19 @@ "terser": "5.44.1" }, "devDependencies": { - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@misskey-dev/eslint-plugin": "2.2.0", "@types/js-yaml": "4.0.9", - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript/native-preview": "7.0.0-dev.20251206.1", + "@types/node": "24.10.4", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@typescript/native-preview": "7.0.0-dev.20251226.1", "cross-env": "10.1.0", - "cypress": "15.7.1", - "eslint": "9.39.1", + "cypress": "15.8.1", + "eslint": "9.39.2", "globals": "16.5.0", "ncp": "2.0.0", - "pnpm": "10.25.0", + "pnpm": "10.26.2", "typescript": "5.9.3", "start-server-and-test": "2.1.3" }, diff --git a/packages/backend/migration/1767169026317-birthday-index.js b/packages/backend/migration/1767169026317-birthday-index.js new file mode 100644 index 0000000000..972fc08c9b --- /dev/null +++ b/packages/backend/migration/1767169026317-birthday-index.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class BirthdayIndex1767169026317 { + name = 'BirthdayIndex1767169026317' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`); + await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`); + await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`); + } + + async down(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`); + await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`); + await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 96ef6833d8..d985e9c277 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -41,20 +41,20 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.15.3", - "@swc/core-darwin-x64": "1.15.3", + "@swc/core-darwin-arm64": "1.15.7", + "@swc/core-darwin-x64": "1.15.7", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.15.3", - "@swc/core-linux-arm64-gnu": "1.15.3", - "@swc/core-linux-arm64-musl": "1.15.3", - "@swc/core-linux-x64-gnu": "1.15.3", - "@swc/core-linux-x64-musl": "1.15.3", - "@swc/core-win32-arm64-msvc": "1.15.3", - "@swc/core-win32-ia32-msvc": "1.15.3", - "@swc/core-win32-x64-msvc": "1.15.3", + "@swc/core-linux-arm-gnueabihf": "1.15.7", + "@swc/core-linux-arm64-gnu": "1.15.7", + "@swc/core-linux-arm64-musl": "1.15.7", + "@swc/core-linux-x64-gnu": "1.15.7", + "@swc/core-linux-x64-musl": "1.15.7", + "@swc/core-win32-arm64-msvc": "1.15.7", + "@swc/core-win32-ia32-msvc": "1.15.7", + "@swc/core-win32-x64-msvc": "1.15.7", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10", @@ -68,11 +68,11 @@ "slacc-linux-x64-musl": "0.0.10", "slacc-win32-arm64-msvc": "0.0.10", "slacc-win32-x64-msvc": "0.0.10", - "utf-8-validate": "6.0.5" + "utf-8-validate": "6.0.6" }, "dependencies": { - "@aws-sdk/client-s3": "3.948.0", - "@aws-sdk/lib-storage": "3.948.0", + "@aws-sdk/client-s3": "3.958.0", + "@aws-sdk/lib-storage": "3.958.0", "@discordapp/twemoji": "16.0.1", "@fastify/accepts": "5.0.4", "@fastify/cors": "11.2.0", @@ -83,18 +83,18 @@ "@kitajs/html": "4.2.11", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.2.5", - "@napi-rs/canvas": "0.1.84", - "@nestjs/common": "11.1.9", - "@nestjs/core": "11.1.9", - "@nestjs/testing": "11.1.9", + "@napi-rs/canvas": "0.1.87", + "@nestjs/common": "11.1.10", + "@nestjs/core": "11.1.10", + "@nestjs/testing": "11.1.10", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.29.0", - "@sentry/profiling-node": "10.29.0", + "@sentry/node": "10.32.1", + "@sentry/profiling-node": "10.32.1", "@simplewebauthn/server": "13.2.2", - "@sinonjs/fake-timers": "15.0.0", - "@smithy/node-http-handler": "4.4.5", + "@sinonjs/fake-timers": "15.1.0", + "@smithy/node-http-handler": "4.4.7", "@swc/cli": "0.7.9", - "@swc/core": "1.15.3", + "@swc/core": "1.15.7", "@twemoji/parser": "16.0.0", "@types/redis-info": "3.0.3", "accepts": "1.3.8", @@ -104,7 +104,7 @@ "bcryptjs": "3.0.3", "blurhash": "2.0.5", "body-parser": "2.2.1", - "bullmq": "5.65.1", + "bullmq": "5.66.3", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", "chalk-template": "1.1.2", @@ -116,7 +116,7 @@ "fastify": "5.6.2", "fastify-raw-body": "5.0.0", "feed": "5.1.0", - "file-type": "21.1.1", + "file-type": "21.2.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.5", "got": "14.6.5", @@ -140,7 +140,7 @@ "nested-property": "4.0.0", "node-fetch": "3.3.2", "node-html-parser": "7.0.1", - "nodemailer": "7.0.11", + "nodemailer": "7.0.12", "nsfwjs": "4.2.0", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", @@ -153,7 +153,7 @@ "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.22.3", + "re2": "1.23.0", "redis-info": "3.1.0", "reflect-metadata": "0.2.2", "rename": "1.0.4", @@ -166,7 +166,7 @@ "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.27.14", + "systeminformation": "5.28.1", "tinycolor2": "1.6.0", "tmp": "0.2.5", "tsc-alias": "1.8.16", @@ -180,8 +180,8 @@ "devDependencies": { "@jest/globals": "29.7.0", "@kitajs/ts-html-plugin": "4.1.3", - "@nestjs/platform-express": "11.1.9", - "@sentry/vue": "10.29.0", + "@nestjs/platform-express": "11.1.10", + "@sentry/vue": "10.32.1", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.39", "@types/accepts": "1.3.7", @@ -195,11 +195,11 @@ "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", "@types/ms": "2.1.0", - "@types/node": "24.10.2", + "@types/node": "24.10.4", "@types/nodemailer": "7.0.4", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.15.6", + "@types/pg": "8.16.0", "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", @@ -214,8 +214,8 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", "aws-sdk-client-mock": "4.1.0", "cbor": "10.0.11", "cross-env": "10.1.0", @@ -230,6 +230,6 @@ "pid-port": "2.0.0", "simple-oauth2": "5.1.0", "supertest": "7.1.4", - "vite": "7.2.7" + "vite": "7.3.0" } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ac5b855096..0f4051e7b8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit { me, { ...options, - userProfile: profilesMap.get(u.id), + userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 9aecc0f0fd..9311c80eaa 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; export * as 'users/followers' from './endpoints/users/followers.js'; export * as 'users/following' from './endpoints/users/following.js'; +export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js'; export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js'; export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js'; export * as 'users/lists/create' from './endpoints/users/lists/create.js'; diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 047f9a053b..326e56bc85 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -86,7 +86,7 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - birthday: { ...birthdaySchema, nullable: true }, + birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' }, }, }, ], @@ -146,14 +146,15 @@ export default class extends Endpoint { // eslint- .andWhere('following.followerId = :userId', { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); + // @deprecated use get-following-birthday-users instead. if (ps.birthday) { - try { - const birthday = ps.birthday.substring(5, 10); - const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); - birthdayUserQuery.select('user_profile.userId') - .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); + query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); - query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`); + try { + const birthday = ps.birthday.split('-'); + birthday.shift(); // 年の部分を削除 + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) }); } catch (err) { throw new ApiError(meta.errors.birthdayInvalid); } diff --git a/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts b/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts new file mode 100644 index 0000000000..124114244e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + FollowingsRepository, + UserProfilesRepository, +} from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { Packed } from '@/misc/json-schema.js'; + +export const meta = { + tags: ['users'], + + requireCredential: true, + kind: 'read:account', + + description: 'Find users who have a birthday on the specified range.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + birthday: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + birthday: { + oneOf: [{ + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, { + type: 'object', + properties: { + begin: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + end: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + }, + required: ['begin', 'end'], + }], + }, + }, + required: ['birthday'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.followingsRepository + .createQueryBuilder('following') + .andWhere('following.followerId = :userId', { userId: me.id }) + .innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); + + if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) { + const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; }; + + // 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換 + const begin = range.begin.month * 100 + range.begin.day; + const end = range.end.month * 100 + range.end.day; + + if (begin <= end) { + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end }); + } else { + // 12/31 から 1/1 の範囲を取得するために OR で対応 + query.andWhere(new Brackets(qb => { + qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin }); + qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end }); + })); + } + } else { + const { month, day } = ps.birthday as { month: number; day: number }; + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day }); + } + + query.select('following.followeeId', 'user_id'); + query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date'); + query.orderBy('birthday_date', 'ASC'); + + const birthdayUsers = await query + .offset(ps.offset).limit(ps.limit) + .getRawMany<{ birthday_date: number; user_id: string }>(); + + const users = new Map>(( + await this.userEntityService.packMany( + birthdayUsers.map(u => u.user_id), + me, + { schema: 'UserLite' }, + ) + ).map(u => [u.id, u])); + + return birthdayUsers + .map(item => { + const birthday = new Date(); + birthday.setHours(0, 0, 0, 0); + // item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定 + birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100); + + if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) { + birthday.setFullYear(new Date().getFullYear() + 1); + } + + const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`; + return { + id: item.user_id, + birthday: birthdayStr, + user: users.get(item.user_id), + }; + }) + .filter(item => item.user != null) + .map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> }); + }); + } +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d2391c43ab..47f4bf947d 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -123,41 +123,84 @@ function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: str return { name, logo }; } -// https://indieauth.spec.indieweb.org/#client-information-discovery -// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, -// and if there is an [h-app] with a url property matching the client_id URL, -// then it should use the name and icon and display them on the authorization prompt." -// (But we don't display any icon for now) -// https://indieauth.spec.indieweb.org/#redirect-url -// "The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute -// of redirect_uri at the client_id URL. -// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST -// look for an exact match of the given redirect_uri in the request against the list of -// redirect_uris discovered after resolving any relative URLs." async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise { try { const res = await httpRequestService.send(id); - const redirectUris: string[] = []; + const redirectUris: string[] = []; + let name = id; + let logo: string | null = null; + + // https://indieauth.spec.indieweb.org/#redirect-url + // "The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute + // of redirect_uri at the client_id URL. + // Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST + // look for an exact match of the given redirect_uri in the request against the list of + // redirect_uris discovered after resolving any relative URLs." const linkHeader = res.headers.get('link'); if (linkHeader) { redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); } - const text = await res.text(); - const doc = htmlParser.parse(`
${text}
`); + if (res.headers.get('content-type')?.includes('application/json')) { + // Client discovery via JSON document (11 July 2024 spec) + // https://indieauth.spec.indieweb.org/#client-metadata + // "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing + // client metadata defined in [RFC7591], the minimum properties for an IndieAuth + // client defined below." - redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + const json = await res.json() as { + client_id: string; + client_name?: string; + client_uri: string; + logo_uri?: string; + redirect_uris?: string[]; + }; - let name = id; - let logo: string | null = null; - if (text) { - const microformats = parseMicroformats(doc, res.url, id); - if (typeof microformats.name === 'string') { - name = microformats.name; + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The authorization server MUST verify that the client_id in the document matches the + // client_id of the URL where the document was retrieved." + if (json.client_id !== id) { + throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request'); } - if (typeof microformats.logo === 'string') { - logo = microformats.logo; + + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The client_uri MUST be a prefix of the client_id." + if (!json.client_uri || !id.startsWith(json.client_uri)) { + throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request'); + } + + if (typeof json.client_name === 'string') { + name = json.client_name; + } + + if (typeof json.logo_uri === 'string') { + // Since uri can be relative, resolve it against the document URL + logo = new URL(json.logo_uri, res.url).toString(); + } + + if (Array.isArray(json.redirect_uris)) { + redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string')); + } + } else { + // Client discovery via HTML microformats (12 February 2022 spec) + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + // "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, + // and if there is an [h-app] with a url property matching the client_id URL, + // then it should use the name and icon and display them on the authorization prompt." + const text = await res.text(); + const doc = htmlParser.parse(`
${text}
`); + + redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + + if (text) { + const microformats = parseMicroformats(doc, res.url, id); + if (typeof microformats.name === 'string') { + name = microformats.name; + } + if (typeof microformats.logo === 'string') { + logo = microformats.logo; + } } } @@ -172,6 +215,8 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt logger.error('Error while fetching client information', { err }); if (err instanceof StatusError) { throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); + } else if (err instanceof AuthorizationError) { + throw err; } else { throw new AuthorizationError('Failed to parse client information', 'server_error'); } diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 96a6311a5a..67a9026eb5 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; +const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`; const basicAuthParams: AuthorizationParamsExtended = { redirect_uri, @@ -807,65 +808,19 @@ describe('OAuth', () => { }); }); - // https://indieauth.spec.indieweb.org/#client-information-discovery describe('Client Information Discovery', () => { - describe('Redirection', () => { - const tests: Record void> = { - 'Read HTTP header': reply => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - }, - 'Mixed links': reply => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - - -
Misklient - `); - }, - 'Multiple items in Link header': reply => { - reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - }, - 'Multiple items in HTML': reply => { - reply.send(` - - - -
Misklient - `); - }, - }; - - for (const [title, replyFunc] of Object.entries(tests)) { - test(title, async () => { - sender = replyFunc; - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - }); - } - - test('No item', async () => { + // https://indieauth.spec.indieweb.org/#client-information-discovery + describe('JSON client metadata (11 July 2024)', () => { + test('Read JSON document', async () => { sender = (reply): void => { - reply.send(` - -
Misklient - `); + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + logo_uri: '/logo.png', + redirect_uris: ['/redirect'], + }); }; const client = new AuthorizationCode(clientConfig); @@ -877,119 +832,294 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient JSON'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); - // direct error because there's no redirect URI to ping + test('Merge Link header redirect_uri with JSON redirect_uris', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + + const ok1 = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(ok1.status, 200); + + const ok2 = await fetch(client.authorizeURL({ + redirect_uri: redirect_uri2, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(ok2.status, 200); + }); + + test('Reject when client_id does not match retrieved URL', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/mismatch`, + client_uri: `http://127.0.0.1:${clientPort}/`, + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + + test('Reject when client_uri is not a prefix of client_id', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`, + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + + test('Reject when JSON metadata has no redirect_uris and no Link header', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); await assertDirectError(response, 400, 'invalid_request'); }); }); - test('Disallow loopback', async () => { - await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + describe('HTML link client metadata (12 Feb 2022)', () => { + describe('Redirection', () => { + const tests: Record void> = { + 'Read HTTP header': reply => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + }, + 'Mixed links': reply => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + + +
Misklient + `); + }, + 'Multiple items in Link header': reply => { + reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + }, + 'Multiple items in HTML': reply => { + reply.send(` + + + +
Misklient + `); + }, + }; - const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); + for (const [title, replyFunc] of Object.entries(tests)) { + test(title, async () => { + sender = replyFunc; - test('Missing name', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(); - }; + const client = new AuthorizationCode(clientConfig); - const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + }); + } - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); - }); + test('No item', async () => { + sender = (reply): void => { + reply.send(` + +
Misklient + `); + }; - test('With Logo', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - - - `); - reply.send(); - }; + const client = new AuthorizationCode(clientConfig); - const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - const meta = getMeta(await response.text()); - assert.strictEqual(meta.clientName, 'Misklient'); - assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); - }); + // direct error because there's no redirect URI to ping + await assertDirectError(response, 400, 'invalid_request'); + }); + }); - test('Missing Logo', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - reply.send(); - }; - const client = new AuthorizationCode(clientConfig); + test('Disallow loopback', async () => { + await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - const meta = getMeta(await response.text()); - assert.strictEqual(meta.clientName, 'Misklient'); - assert.strictEqual(meta.clientLogo, undefined); - }); + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); - test('Mismatching URL in h-app', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - reply.send(); - }; + test('Missing name', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(); + }; - const client = new AuthorizationCode(clientConfig); + const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); + + test('With Logo', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + + + `); + reply.send(); + }; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); + + test('Missing Logo', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + reply.send(); + }; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, undefined); + }); + + test('Mismatching URL in h-app', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + reply.send(); + }; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); }); }); diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index e6e8899a12..8e77c0f5c3 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -11,15 +11,15 @@ }, "devDependencies": { "@types/estree": "1.0.8", - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "rollup": "4.53.3" + "@types/node": "24.10.4", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "rollup": "4.54.0" }, "dependencies": { "i18n": "workspace:*", "estree-walker": "3.0.3", "magic-string": "0.30.21", - "vite": "7.2.7" + "vite": "7.3.0" } } diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 7a40ae3507..4d5e73cf32 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -16,7 +16,7 @@ "@rollup/plugin-replace": "6.0.3", "@rollup/pluginutils": "5.3.0", "@twemoji/parser": "16.0.0", - "@vitejs/plugin-vue": "6.0.2", + "@vitejs/plugin-vue": "6.0.3", "buraha": "0.0.1", "estree-walker": "3.0.3", "frontend-shared": "workspace:*", @@ -25,13 +25,13 @@ "mfm-js": "0.25.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.53.3", - "sass": "1.95.1", - "shiki": "3.19.0", + "rollup": "4.54.0", + "sass": "1.97.1", + "shiki": "3.20.0", "tinycolor2": "1.6.0", "uuid": "13.0.0", - "vite": "7.2.7", - "vue": "3.5.25" + "vite": "7.3.0", + "vue": "3.5.26" }, "devDependencies": { "@misskey-dev/summaly": "5.2.5", @@ -39,14 +39,14 @@ "@testing-library/vue": "8.1.0", "@types/estree": "1.0.8", "@types/micromatch": "4.0.10", - "@types/node": "24.10.2", + "@types/node": "24.10.4", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@vitest/coverage-v8": "4.0.15", - "@vue/runtime-core": "3.5.25", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@vitest/coverage-v8": "4.0.16", + "@vue/runtime-core": "3.5.26", "acorn": "8.15.0", "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", @@ -54,13 +54,13 @@ "happy-dom": "20.0.11", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.12.4", + "msw": "2.12.6", "nodemon": "3.1.11", "prettier": "3.7.4", "start-server-and-test": "2.1.3", "tsx": "4.21.0", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "3.1.8", + "vue-component-type-helpers": "3.2.1", "vue-eslint-parser": "10.2.0", "vue-tsc": "3.1.8" } diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index b8c927804e..53343907de 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,10 +21,10 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "esbuild": "0.27.1", + "@types/node": "24.10.4", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "esbuild": "0.27.2", "eslint-plugin-vue": "10.6.2", "nodemon": "3.1.11", "vue-eslint-parser": "10.2.0" @@ -35,6 +35,6 @@ "dependencies": { "i18n": "workspace:*", "misskey-js": "workspace:*", - "vue": "3.5.25" + "vue": "3.5.26" } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 932fd7fad1..f04906fd16 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -25,11 +25,11 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", "@rollup/pluginutils": "5.3.0", - "@sentry/vue": "10.29.0", - "@syuilo/aiscript": "1.2.0", + "@sentry/vue": "10.32.1", + "@syuilo/aiscript": "1.2.1", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", "@twemoji/parser": "16.0.0", - "@vitejs/plugin-vue": "6.0.2", + "@vitejs/plugin-vue": "6.0.3", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16", "analytics": "0.8.19", "broadcast-channel": "7.2.0", @@ -55,7 +55,7 @@ "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.20.0", - "mediabunny": "1.25.8", + "mediabunny": "1.27.2", "mfm-js": "0.25.0", "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", @@ -64,59 +64,59 @@ "punycode.js": "2.3.1", "qr-code-styling": "1.9.2", "qr-scanner": "1.4.2", - "rollup": "4.53.3", + "rollup": "4.54.0", "sanitize-html": "2.17.0", - "sass": "1.95.1", - "shiki": "3.19.0", + "sass": "1.97.1", + "shiki": "3.20.0", "textarea-caret": "3.1.0", - "three": "0.181.2", + "three": "0.182.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "v-code-diff": "1.13.1", - "vite": "7.2.7", - "vue": "3.5.25", + "vite": "7.3.0", + "vue": "3.5.26", "vuedraggable": "next", "wanakana": "5.3.1" }, "devDependencies": { "@misskey-dev/summaly": "5.2.5", - "@storybook/addon-essentials": "8.6.14", - "@storybook/addon-interactions": "8.6.14", - "@storybook/addon-links": "10.1.5", - "@storybook/addon-mdx-gfm": "8.6.14", - "@storybook/addon-storysource": "8.6.14", - "@storybook/blocks": "8.6.14", - "@storybook/components": "8.6.14", - "@storybook/core-events": "8.6.14", - "@storybook/manager-api": "8.6.14", - "@storybook/preview-api": "8.6.14", - "@storybook/react": "10.1.5", - "@storybook/react-vite": "10.1.5", - "@storybook/test": "8.6.14", - "@storybook/theming": "8.6.14", - "@storybook/types": "8.6.14", - "@storybook/vue3": "10.1.5", - "@storybook/vue3-vite": "10.1.5", + "@storybook/addon-essentials": "8.6.15", + "@storybook/addon-interactions": "8.6.15", + "@storybook/addon-links": "10.1.10", + "@storybook/addon-mdx-gfm": "8.6.15", + "@storybook/addon-storysource": "8.6.15", + "@storybook/blocks": "8.6.15", + "@storybook/components": "8.6.15", + "@storybook/core-events": "8.6.15", + "@storybook/manager-api": "8.6.15", + "@storybook/preview-api": "8.6.15", + "@storybook/react": "10.1.10", + "@storybook/react-vite": "10.1.10", + "@storybook/test": "8.6.15", + "@storybook/theming": "8.6.15", + "@storybook/types": "8.6.15", + "@storybook/vue3": "10.1.10", + "@storybook/vue3-vite": "10.1.10", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.8", "@types/matter-js": "0.20.2", "@types/micromatch": "4.0.10", - "@types/node": "24.10.2", + "@types/node": "24.10.4", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@vitest/coverage-v8": "4.0.15", - "@vue/compiler-core": "3.5.25", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@vitest/coverage-v8": "4.0.16", + "@vue/compiler-core": "3.5.26", "acorn": "8.15.0", "astring": "1.9.0", "cross-env": "10.1.0", - "cypress": "15.7.1", + "cypress": "15.8.1", "eslint-plugin-import": "2.32.0", "eslint-plugin-vue": "10.6.2", "estree-walker": "3.0.3", @@ -125,22 +125,22 @@ "magic-string": "0.30.21", "micromatch": "4.0.8", "minimatch": "10.1.1", - "msw": "2.12.4", + "msw": "2.12.6", "msw-storybook-addon": "2.0.6", "nodemon": "3.1.11", "prettier": "3.7.4", - "react": "19.2.1", - "react-dom": "19.2.1", + "react": "19.2.3", + "react-dom": "19.2.3", "seedrandom": "3.0.5", "start-server-and-test": "2.1.3", - "storybook": "10.1.5", + "storybook": "10.1.10", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "tsx": "4.21.0", "vite-plugin-glsl": "1.5.5", "vite-plugin-turbosnap": "1.0.3", - "vitest": "4.0.15", + "vitest": "4.0.16", "vitest-fetch-mock": "0.4.5", - "vue-component-type-helpers": "3.1.8", + "vue-component-type-helpers": "3.2.1", "vue-eslint-parser": "10.2.0", "vue-tsc": "3.1.8" } diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 314a776c54..71ffb252df 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -133,12 +133,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.loadMore }}
@@ -238,10 +238,9 @@ const filesPaginator = markRaw(new Paginator('drive/files', { params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない folderId: folder.value ? folder.value.id : null, type: props.type, - sort: sortModeSelect.value, + sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value, }), })); - const foldersPaginator = markRaw(new Paginator('drive/folders', { limit: 30, canFetchDetection: 'limit', @@ -250,6 +249,16 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', { }), })); +const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value)); + +async function fetchMoreFiles() { + if (filesPaginator.order.value === 'oldest') { + filesPaginator.fetchNewer(); + } else { + filesPaginator.fetchOlder(); + } +} + const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month'); const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value)); @@ -260,10 +269,10 @@ watch(sortModeSelect, () => { async function initialize() { fetching.value = true; - await Promise.all([ - foldersPaginator.init(), - filesPaginator.init(), - ]); + await foldersPaginator.reload(); + filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older'; + filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest'; + await filesPaginator.reload(); fetching.value = false; } diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ba21fe82e4..72a24411c1 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -81,7 +81,13 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { } async function onClick() { - pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } }); + const isLoggedIn = await pleaseLogin({ + openOnRemote: { + type: 'web', + path: `/@${props.user.username}@${props.user.host ?? host}`, + }, + }); + if (!isLoggedIn) return; wait.value = true; diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index e3bb39549f..efcbf26a29 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; @@ -154,16 +155,11 @@ function hasFocus() { const playerEl = useTemplateRef('playerEl'); const audioEl = useTemplateRef('audioEl'); -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); +const hide = ref(shouldHideFileByDefault(props.audio)); async function reveal() { - if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.audio))) { + return; } hide.value = false; diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 7730e01a9f..fd86b61b87 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only