diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d80c56f032..33a1ccbc76 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -111,10 +111,5 @@ jobs: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - - run: pnpm --filter misskey-js run build - if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'frontend' || matrix.workspace == 'sw' }} - - run: pnpm --filter misskey-reversi run build - if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'frontend' }} - - run: pnpm --filter misskey-bubble-game run build - if: ${{ matrix.workspace == 'frontend' }} + - run: pnpm --filter "${{ matrix.workspace }}^..." run build - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index e63d83997b..d75335f38f 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -3,10 +3,12 @@ name: Lint on: push: paths: + - packages/i18n/** - locales/** - .github/workflows/locale.yml pull_request: paths: + - packages/i18n/** - locales/** - .github/workflows/locale.yml jobs: @@ -14,15 +16,18 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4.3.0 - with: - fetch-depth: 0 - submodules: true - - name: Setup pnpm - uses: pnpm/action-setup@v4.2.0 - - uses: actions/setup-node@v4.4.0 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: pnpm i --frozen-lockfile - - run: cd locales && node verify.js + - uses: actions/checkout@v4.3.0 + with: + fetch-depth: 0 + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v4.2.0 + - uses: actions/setup-node@v4.4.0 + with: + node-version-file: ".node-version" + cache: "pnpm" + - run: pnpm i --frozen-lockfile + - run: pnpm --filter i18n build + - name: Verify Locales + working-directory: ./packages/i18n + run: pnpm run verify diff --git a/CHANGELOG.md b/CHANGELOG.md index 616e726940..90f749b0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,19 @@ -## 2025.11.1 +## 2025.11.2 ### General - +### Client +- + +### Server +- Enhance: メモリ使用量を削減しました +- Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上 +- Enhance: 依存関係の更新 + + +## 2025.11.1 + ### Client - Enhance: リアクションの受け入れ設定にキャプションを追加 #15921 @@ -17,9 +28,10 @@ (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1129) - Fix: フォロー申請をキャンセルする際の確認ダイアログの文言が不正確な問題を修正 - Fix: 初回読み込み時にエラーになることがある問題を修正 +- Fix: お気に入りクリップの一覧表示が正しく動作しない問題を修正 +- Fix: AiScript Misskey 拡張APIにおいて、各種関数の引数で明示的に `null` が指定されている場合のハンドリングを修正 ### Server -- Enhance: `clips/my-favorites` APIがページネーションに対応しました - Enhance: メモリ使用量を削減しました - Enhance: 依存関係の更新 - Fix: ワードミュートの文字数計算を修正 diff --git a/Dockerfile b/Dockerfile index 20e24d1dc2..a071970927 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-share COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"] +COPY --link ["packages/i18n/package.json", "./packages/i18n/"] COPY --link ["packages/icons-subsetter/package.json", "./packages/icons-subsetter/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] @@ -101,6 +102,7 @@ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 1730bdbe79..59768668ef 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -83,6 +83,8 @@ files: "Fitxers" download: "Descarregar" driveFileDeleteConfirm: "Estàs segur que vols suprimir el fitxer \"{name}\"? Les notes associades a aquest fitxer també seran esborrades." unfollowConfirm: "Segur que vols deixar de seguir a {name}?" +cancelFollowRequestConfirm: "Vols cancel·lar la teva sol·licitud de seguiment a {name}?" +rejectFollowRequestConfirm: "Vols rebutjar la sol·licitud de seguiment de {name}?" exportRequested: "Has sol·licitat una exportació de dades. Això pot trigar una estona. S'afegirà a la teva unitat de disc un cop estigui completada." importRequested: "Has sol·licitat una importació de dades. Això pot trigar una estona." lists: "Llistes" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 50c98488a3..c442d69c2b 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -83,6 +83,8 @@ files: "Archivos" download: "Descargar" driveFileDeleteConfirm: "¿Desea borrar el archivo \"{name}\"? Las notas que tengan este archivo como adjunto serán eliminadas" unfollowConfirm: "¿Desea dejar de seguir a {name}?" +cancelFollowRequestConfirm: "¿Desea cancelar su solicitud de seguimiento a {name}?" +rejectFollowRequestConfirm: "¿Desea rechazar la solicitud de seguimiento de {name}?" exportRequested: "Has solicitado la exportación. Puede llevar un tiempo. Cuando termine la exportación, se añadirá al drive" importRequested: "Has solicitado la importación. Puede llevar un tiempo." lists: "Listas" @@ -126,7 +128,7 @@ pinnedNote: "Nota fijada" pinned: "Fijar al perfil" you: "Tú" clickToShow: "Haz clic para verlo" -sensitive: "Marcado como sensible" +sensitive: "Marcado como sensible (NSFW)" add: "Agregar" reaction: "Reacción" reactions: "Reacciones" @@ -141,7 +143,7 @@ rememberNoteVisibility: "Recordar visibilidad" attachCancel: "Quitar adjunto" deleteFile: "Eliminar archivo" markAsSensitive: "Marcar como sensible" -unmarkAsSensitive: "Desmarcar como sensible" +unmarkAsSensitive: "No marcar como sensible" enterFileName: "Introduce el nombre del archivo" mute: "Silenciar" unmute: "Dejar de silenciar" @@ -351,7 +353,7 @@ emptyFolder: "La carpeta está vacía" dropHereToUpload: "Arrastra los archivos aquí para subirlos." unableToDelete: "No se puede borrar" inputNewFileName: "Ingrese un nuevo nombre de archivo" -inputNewDescription: "Ingrese nueva descripción" +inputNewDescription: "Introducir un nuevo texto alternativo" inputNewFolderName: "Ingrese un nuevo nombre de la carpeta" circularReferenceFolder: "La carpeta de destino es una sub-carpeta de la carpeta que quieres mover." hasChildFilesOrFolders: "No se puede borrar esta carpeta. No está vacía." @@ -704,7 +706,7 @@ userSaysSomethingAbout: "{name} dijo algo sobre {word}" makeActive: "Activar" display: "Apariencia" copy: "Copiar" -copiedToClipboard: "Texto copiado al portapapeles" +copiedToClipboard: "Copiado al portapapeles" metrics: "Métricas" overview: "Resumen" logs: "Registros" @@ -713,7 +715,7 @@ database: "Base de datos" channel: "Canal" create: "Crear" notificationSetting: "Ajustes de Notificaciones" -notificationSettingDesc: "Por favor elija el tipo de notificación a mostrar" +notificationSettingDesc: "Por favor elige el tipo de notificación a mostrar" useGlobalSetting: "Usar ajustes globales" useGlobalSettingDesc: "Al activarse, se usará la configuración de notificaciones de la cuenta, al desactivarse se pueden hacer configuraciones particulares." other: "Otro" @@ -745,7 +747,7 @@ system: "Sistema" switchUi: "Cambiar interfaz de usuario" desktop: "Escritorio" clip: "Clip" -createNew: "Crear" +createNew: "Crear Nuevo" optional: "Opcional" createNewClip: "Crear clip nuevo" unclip: "Quitar clip" @@ -1201,8 +1203,8 @@ iHaveReadXCarefullyAndAgree: "He leído el texto {x} y estoy de acuerdo" dialog: "Diálogo" icon: "Avatar" forYou: "Para ti" -currentAnnouncements: "Anuncios actuales" -pastAnnouncements: "Anuncios anteriores" +currentAnnouncements: "Avisos actuales" +pastAnnouncements: "Avisos anteriores" youHaveUnreadAnnouncements: "Hay anuncios sin leer" useSecurityKey: "Por favor, sigue las instrucciones de tu dispositivo o navegador para usar tu clave de seguridad o tu clave de paso." replies: "Responder" @@ -1410,8 +1412,8 @@ _imageEditing: filename: "Nombre de archivo" filename_without_ext: "Nombre del archivo sin la extensión" year: "Año de rodaje" - month: "Mes de rodaje" - day: "Día de rodaje" + month: "Mes de la fotografía" + day: "Día de la fotografía" hour: "Hora" minute: "Minuto" second: "Segundo" @@ -1425,9 +1427,9 @@ _imageEditing: gps_lat: "Latitud" gps_long: "Longitud" _imageFrameEditor: - title: "Edición de Fotograma" + title: "Edición de Fotos" tip: "Decora tus imágenes con marcos y etiquetas que contengan metadatos." - header: "Cabezal" + header: "Título" footer: "Pie de página" borderThickness: "Ancho del borde" labelThickness: "Ancho de la etiqueta" @@ -1454,8 +1456,8 @@ _compression: medium: "Tamaño mediano" small: "Tamaño pequeño" _order: - newest: "Los más recientes primero" - oldest: "Los más antiguos primero" + newest: "Más reciente primero" + oldest: "Más antiguos primero" _chat: messages: "Mensajes" noMessagesYet: "Aún no hay mensajes" @@ -1683,7 +1685,7 @@ _initialTutorial: followers: "Visible solo para seguidores. Sólo tus seguidores podrán ver la nota, y no podrá ser renotada por otras personas." direct: "Visible sólo para usuarios específicos, y el destinatario será notificado. Puede usarse como alternativa a la mensajería directa." doNotSendConfidencialOnDirect1: "¡Ten cuidado cuando vayas a enviar información sensible!" - doNotSendConfidencialOnDirect2: "Los administradores del servidor pueden leer lo que escribes. Ten cuidado cuando envíes información sensible en notas directas en servidores no confiables." + doNotSendConfidencialOnDirect2: "Los administradores del servidor, también llamado instancia, pueden leer lo que escribes. Ten cuidado cuando envíes información sensible en notas directas en servidores o instancias no confiables." localOnly: "Publicando con esta opción seleccionada, la nota no se federará hacia otros servidores. Los usuarios de otros servidores no podrán ver estas notas directamente, sin importar los ajustes seleccionados más arriba." _cw: title: "Alerta de contenido (CW)" @@ -2297,7 +2299,7 @@ _theme: indicator: "Indicador" panel: "Panel" shadow: "Sombra" - header: "Cabezal" + header: "Título" navBg: "Fondo de la barra lateral" navFg: "Texto de la barra lateral" navActive: "Texto de la barra lateral (activo)" @@ -2769,7 +2771,7 @@ _notification: follow: "Siguiendo" mention: "Menciones" reply: "Respuestas" - renote: "Renotar" + renote: "Renotas" quote: "Citar" reaction: "Reacción" pollEnded: "La encuesta terminó" diff --git a/locales/generateDTS.js b/locales/generateDTS.js deleted file mode 100644 index ab0613cc82..0000000000 --- a/locales/generateDTS.js +++ /dev/null @@ -1,232 +0,0 @@ -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as yaml from 'js-yaml'; -import ts from 'typescript'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const parameterRegExp = /\{(\w+)\}/g; - -function createMemberType(item) { - if (typeof item !== 'string') { - return ts.factory.createTypeLiteralNode(createMembers(item)); - } - const parameters = Array.from( - item.matchAll(parameterRegExp), - ([, parameter]) => parameter, - ); - return parameters.length - ? ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ParameterizedString'), - [ - ts.factory.createUnionTypeNode( - parameters.map((parameter) => - ts.factory.createStringLiteral(parameter), - ), - ), - ], - ) - : ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} - -function createMembers(record) { - return Object.entries(record).map(([k, v]) => { - const node = ts.factory.createPropertySignature( - undefined, - ts.factory.createStringLiteral(k), - undefined, - createMemberType(v), - ); - if (typeof v === 'string') { - ts.addSyntheticLeadingComment( - node, - ts.SyntaxKind.MultiLineCommentTrivia, - `* - * ${v.replace(/\n/g, '\n * ')} - `, - true, - ); - } - return node; - }); -} - -export default function generateDTS() { - const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); - const members = createMembers(locale); - const elements = [ - ts.factory.createVariableStatement( - [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('kParameters'), - undefined, - ts.factory.createTypeOperatorNode( - ts.SyntaxKind.UniqueKeyword, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.SymbolKeyword), - ), - undefined, - ), - ], - ts.NodeFlags.Const, - ), - ), - ts.factory.createTypeAliasDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('ParameterizedString'), - [ - ts.factory.createTypeParameterDeclaration( - undefined, - ts.factory.createIdentifier('T'), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ), - ], - ts.factory.createIntersectionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - ts.factory.createComputedPropertyName( - ts.factory.createIdentifier('kParameters'), - ), - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('T'), - undefined, - ), - ), - ]) - ]), - ), - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('ILocale'), - undefined, - undefined, - [ - ts.factory.createIndexSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier('_'), - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - undefined, - ), - ], - ts.factory.createUnionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ParameterizedString'), - ), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ILocale'), - undefined, - ), - ]), - ), - ], - ), - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('Locale'), - undefined, - [ - ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ - ts.factory.createExpressionWithTypeArguments( - ts.factory.createIdentifier('ILocale'), - undefined, - ), - ]), - ], - members, - ), - ts.factory.createVariableStatement( - [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('locales'), - undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createIndexSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier('lang'), - undefined, - ts.factory.createKeywordTypeNode( - ts.SyntaxKind.StringKeyword, - ), - undefined, - ), - ], - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Locale'), - undefined, - ), - ), - ]), - undefined, - ), - ], - ts.NodeFlags.Const, - ), - ), - ts.factory.createFunctionDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - ts.factory.createIdentifier('build'), - undefined, - [], - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Locale'), - undefined, - ), - undefined, - ), - ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), - ]; - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.MultiLineCommentTrivia, - ' eslint-disable ', - true, - ); - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ' This file is generated by locales/generateDTS.js', - true, - ); - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ' Do not edit this file directly.', - true, - ); - const printed = ts - .createPrinter({ - newLine: ts.NewLineKind.LineFeed, - }) - .printList( - ts.ListFormat.MultiLine, - ts.factory.createNodeArray(elements), - ts.createSourceFile( - 'index.d.ts', - '', - ts.ScriptTarget.ESNext, - true, - ts.ScriptKind.TS, - ), - ); - - fs.writeFileSync(`${__dirname}/index.d.ts`, printed, 'utf-8'); -} diff --git a/locales/index.js b/locales/index.js deleted file mode 100644 index 6d9cf4796b..0000000000 --- a/locales/index.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Languages Loader - */ - -import * as fs from 'node:fs'; -import * as yaml from 'js-yaml'; - -const merge = (...args) => args.reduce((a, c) => ({ - ...a, - ...c, - ...Object.entries(a) - .filter(([k]) => c && typeof c[k] === 'object') - .reduce((a, [k, v]) => (a[k] = merge(v, c[k]), a), {}) -}), {}); - -const languages = [ - 'ar-SA', - 'ca-ES', - 'cs-CZ', - 'da-DK', - 'de-DE', - 'en-US', - 'es-ES', - 'fr-FR', - 'id-ID', - 'it-IT', - 'ja-JP', - 'ja-KS', - 'kab-KAB', - 'kn-IN', - 'ko-KR', - 'nl-NL', - 'no-NO', - 'pl-PL', - 'pt-PT', - 'ru-RU', - 'sk-SK', - 'th-TH', - 'tr-TR', - 'ug-CN', - 'uk-UA', - 'vi-VN', - 'zh-CN', - 'zh-TW', -]; - -const primaries = { - 'en': 'US', - 'ja': 'JP', - 'zh': 'CN', -}; - -// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く -const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); - -export function build() { - // vitestの挙動を調整するため、一度ローカル変数化する必要がある - // https://github.com/vitest-dev/vitest/issues/3988#issuecomment-1686599577 - // https://github.com/misskey-dev/misskey/pull/14057#issuecomment-2192833785 - const metaUrl = import.meta.url; - const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, metaUrl), 'utf-8'))) || {}, a), {}); - - // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す - const removeEmpty = (obj) => { - for (const [k, v] of Object.entries(obj)) { - if (v === '') { - delete obj[k]; - } else if (typeof v === 'object') { - removeEmpty(v); - } - } - return obj; - }; - removeEmpty(locales); - - return Object.entries(locales) - .reduce((a, [k, v]) => (a[k] = (() => { - const [lang] = k.split('-'); - switch (k) { - case 'ja-JP': return v; - case 'ja-KS': - case 'en-US': return merge(locales['ja-JP'], v); - default: return merge( - locales['ja-JP'], - locales['en-US'], - locales[`${lang}-${primaries[lang]}`] ?? {}, - v - ); - } - })(), a), {}); -} - -export default build(); diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 615e70af2f..5a70bffeef 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -83,6 +83,8 @@ files: "파일" download: "다운로드" driveFileDeleteConfirm: "‘{name}’ 파일을 삭제하시겠습니까? 이 파일을 사용하는 일부 콘텐츠도 삭제됩니다." unfollowConfirm: "{name}님을 언팔로우하시겠습니까?" +cancelFollowRequestConfirm: "{name}(으)로의 팔로우 신청을 취소하시겠습니까?" +rejectFollowRequestConfirm: "{name}(으)로부터의 팔로우 신청을 거부하시겠습니까?" exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다." importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다." lists: "리스트" diff --git a/locales/package.json b/locales/package.json deleted file mode 100644 index bedb411a91..0000000000 --- a/locales/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/locales/verify.js b/locales/verify.js deleted file mode 100644 index a8e9875d6e..0000000000 --- a/locales/verify.js +++ /dev/null @@ -1,53 +0,0 @@ -import locales from './index.js'; - -let valid = true; - -function writeError(type, lang, tree, data) { - process.stderr.write(JSON.stringify({ type, lang, tree, data })); - process.stderr.write('\n'); - valid = false; -} - -function verify(expected, actual, lang, trace) { - for (let key in expected) { - if (!Object.prototype.hasOwnProperty.call(actual, key)) { - continue; - } - if (typeof expected[key] === 'object') { - if (typeof actual[key] !== 'object') { - writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'object', actual: typeof actual[key] }); - continue; - } - verify(expected[key], actual[key], lang, trace ? `${trace}.${key}` : key); - } else if (typeof expected[key] === 'string') { - switch (typeof actual[key]) { - case 'object': - writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'string', actual: 'object' }); - break; - case 'undefined': - continue; - case 'string': - const expectedParameters = new Set(expected[key].match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); - const actualParameters = new Set(actual[key].match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); - for (let parameter of expectedParameters) { - if (!actualParameters.has(parameter)) { - writeError('missing_parameter', lang, trace ? `${trace}.${key}` : key, { parameter }); - } - } - } - } - } -} - -const { ['ja-JP']: original, ...verifiees } = locales; - -for (let lang in verifiees) { - if (!Object.prototype.hasOwnProperty.call(locales, lang)) { - continue; - } - verify(original, verifiees[lang], lang); -} - -if (!valid) { - process.exit(1); -} diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 2bb5a37895..c5eb96fbb3 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -83,6 +83,8 @@ files: "文件" download: "下载" driveFileDeleteConfirm: "要删除「{name}」文件吗?附加此文件的帖子也会被删除。" unfollowConfirm: "要取消对 {name} 的关注吗?" +cancelFollowRequestConfirm: "要取消申请关注{name}吗?" +rejectFollowRequestConfirm: "要拒绝{name}的关注申请吗?" exportRequested: "导出请求已提交,这可能需要花一些时间,导出的文件将保存到网盘中。" importRequested: "导入请求已提交,这可能需要花一点时间。" lists: "列表" @@ -875,7 +877,7 @@ noInquiryUrlWarning: "尚未设置联络地址。" noBotProtectionWarning: "尚未设置 Bot 防御。" configure: "设置" postToGallery: "创建新图集" -postToHashtag: "投稿到这个标签" +postToHashtag: "发布至该话题" gallery: "图集" recentPosts: "最新发布" popularPosts: "热门投稿" @@ -2557,13 +2559,13 @@ _poll: deadlineTime: "时间" duration: "期限" votesCount: "{n}票" - totalVotes: "总票数 {n}" + totalVotes: "总计{n}票" vote: "投票" - showResult: "显示结果" + showResult: "查看结果" voted: "已投票" closed: "已截止" remainingDays: "{d}天{h}小时后截止" - remainingHours: "{h} 小时 {m} 分后截止" + remainingHours: "{h}小时{m}分后截止" remainingMinutes: "{m}分{s}秒后截止" remainingSeconds: "{s}秒后截止" _visibility: @@ -3144,7 +3146,7 @@ _selfXssPrevention: description3: "详情请看这里。{link}" _followRequest: recieved: "收到的请求" - sent: "已发送的请求" + sent: "发送的请求" _remoteLookupErrors: _federationNotAllowed: title: "无法与此服务器通信" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index db9c1116c3..5227423d84 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -83,6 +83,8 @@ files: "檔案" download: "下載" driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此檔案的貼文也會跟著被刪除。" unfollowConfirm: "確定要取消追隨{name}嗎?" +cancelFollowRequestConfirm: "要取消向 {name} 送出的追隨申請嗎?" +rejectFollowRequestConfirm: "要拒絕來自 {name} 的追隨申請嗎?" exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端硬碟裡。" importRequested: "已請求匯入。這可能會花一點時間。" lists: "清單" diff --git a/package.json b/package.json index c2d7ab67cc..17244e9f3e 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,24 @@ { "name": "misskey", - "version": "2025.11.1-beta.1", + "version": "2025.11.2-alpha.1", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@10.22.0", + "packageManager": "pnpm@10.23.0", "workspaces": [ - "packages/frontend-shared", - "packages/frontend", - "packages/frontend-embed", - "packages/icons-subsetter", - "packages/backend", - "packages/sw", "packages/misskey-js", + "packages/i18n", "packages/misskey-reversi", - "packages/misskey-bubble-game" + "packages/misskey-bubble-game", + "packages/icons-subsetter", + "packages/frontend-shared", + "packages/frontend-builder", + "packages/sw", + "packages/backend", + "packages/frontend", + "packages/frontend-embed" ], "private": true, "scripts": { @@ -58,9 +60,9 @@ "esbuild": "0.27.0", "execa": "9.6.0", "fast-glob": "3.3.3", - "glob": "11.1.0", + "glob": "13.0.0", "ignore-walk": "8.0.0", - "js-yaml": "4.1.0", + "js-yaml": "4.1.1", "postcss": "8.5.6", "tar": "7.5.2", "terser": "5.44.1", @@ -68,18 +70,19 @@ }, "devDependencies": { "@eslint/js": "9.39.1", + "i18n": "workspace:*", "@misskey-dev/eslint-plugin": "2.2.0", "@types/js-yaml": "4.0.9", "@types/node": "24.10.1", "@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/parser": "8.47.0", "cross-env": "10.1.0", - "cypress": "15.6.0", + "cypress": "15.7.0", "eslint": "9.39.1", "globals": "16.5.0", "ncp": "2.0.0", - "pnpm": "10.22.0", - "start-server-and-test": "2.1.2" + "pnpm": "10.23.0", + "start-server-and-test": "2.1.3" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.22.0" diff --git a/packages/backend/package.json b/packages/backend/package.json index 9e511aa5bc..1c3c15bb86 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,17 +40,17 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.15.1", - "@swc/core-darwin-x64": "1.15.1", + "@swc/core-darwin-arm64": "1.15.3", + "@swc/core-darwin-x64": "1.15.3", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.15.1", - "@swc/core-linux-arm64-gnu": "1.15.1", - "@swc/core-linux-arm64-musl": "1.15.1", - "@swc/core-linux-x64-gnu": "1.15.1", - "@swc/core-linux-x64-musl": "1.15.1", - "@swc/core-win32-arm64-msvc": "1.15.1", - "@swc/core-win32-ia32-msvc": "1.15.1", - "@swc/core-win32-x64-msvc": "1.15.1", + "@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", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", "bufferutil": "4.0.9", @@ -70,81 +70,79 @@ "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.927.0", - "@aws-sdk/lib-storage": "3.927.0", + "@aws-sdk/client-s3": "3.937.0", + "@aws-sdk/lib-storage": "3.937.0", "@discordapp/twemoji": "16.0.1", "@fastify/accepts": "5.0.3", "@fastify/cookie": "11.0.2", - "@fastify/cors": "10.1.0", + "@fastify/cors": "11.1.0", "@fastify/express": "4.0.2", - "@fastify/http-proxy": "10.0.2", + "@fastify/http-proxy": "11.3.0", "@fastify/multipart": "9.3.0", "@fastify/static": "8.3.0", - "@fastify/view": "10.0.2", + "@fastify/view": "11.1.1", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.2.5", - "@napi-rs/canvas": "0.1.81", - "@nestjs/common": "11.1.8", - "@nestjs/core": "11.1.8", - "@nestjs/testing": "11.1.8", + "@napi-rs/canvas": "0.1.82", + "@nestjs/common": "11.1.9", + "@nestjs/core": "11.1.9", + "@nestjs/testing": "11.1.9", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.23.0", - "@sentry/profiling-node": "10.23.0", - "@simplewebauthn/server": "12.0.0", - "@sinonjs/fake-timers": "11.3.1", - "@smithy/node-http-handler": "2.5.0", + "@sentry/node": "10.26.0", + "@sentry/profiling-node": "10.26.0", + "@simplewebauthn/server": "13.2.2", + "@sinonjs/fake-timers": "15.0.0", + "@smithy/node-http-handler": "4.4.5", "@swc/cli": "0.7.9", - "@swc/core": "1.15.1", + "@swc/core": "1.15.3", "@twemoji/parser": "16.0.0", "@types/redis-info": "3.0.3", "accepts": "1.3.8", "ajv": "8.17.1", "archiver": "7.0.1", "async-mutex": "0.5.0", - "bcryptjs": "2.4.3", + "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "body-parser": "1.20.3", - "bullmq": "5.63.0", + "body-parser": "2.2.0", + "bullmq": "5.64.1", "cacheable-lookup": "7.0.0", - "cbor": "9.0.2", + "cbor": "10.0.11", "chalk": "5.6.2", "chalk-template": "1.1.2", "chokidar": "4.0.3", - "color-convert": "2.0.1", - "content-disposition": "0.5.4", - "date-fns": "2.30.0", + "color-convert": "3.1.3", + "content-disposition": "1.0.1", + "date-fns": "4.1.0", "deep-email-validator": "0.1.21", "fastify": "5.6.2", "fastify-raw-body": "5.0.0", - "feed": "4.2.2", - "file-type": "21.0.0", + "feed": "5.1.0", + "file-type": "21.1.1", "fluent-ffmpeg": "2.1.3", - "form-data": "4.0.4", - "got": "14.6.3", - "happy-dom": "20.0.10", + "form-data": "4.0.5", + "got": "14.6.4", "hpagent": "1.2.0", - "htmlescape": "1.1.1", "http-link-header": "1.1.3", "ioredis": "5.8.2", "ip-cidr": "4.0.2", "ipaddr.js": "2.2.0", - "is-svg": "5.1.0", - "js-yaml": "4.1.0", - "jsdom": "26.1.0", + "is-svg": "6.1.0", + "js-yaml": "4.1.1", "json5": "2.2.3", - "jsonld": "8.3.3", + "jsonld": "9.0.0", "jsrsasign": "11.1.0", "juice": "11.0.3", "meilisearch": "0.54.0", "mfm-js": "0.25.0", "microformats-parser": "2.0.4", - "mime-types": "2.1.35", + "mime-types": "3.0.2", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.202508261828", "nanoid": "5.1.6", "nested-property": "4.0.0", "node-fetch": "3.3.2", + "node-html-parser": "7.0.1", "nodemailer": "7.0.10", "nsfwjs": "4.2.0", "oauth": "0.10.2", @@ -152,9 +150,8 @@ "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", "otpauth": "9.4.1", - "parse5": "7.3.0", "pg": "8.16.3", - "pkce-challenge": "4.1.0", + "pkce-challenge": "5.0.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.3", @@ -163,13 +160,12 @@ "ratelimiter": "3.4.1", "re2": "1.22.3", "redis-info": "3.1.0", - "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.2", "sanitize-html": "2.17.0", - "secure-json-parse": "3.0.2", + "secure-json-parse": "4.1.0", "semver": "7.7.3", "sharp": "0.33.5", "slacc": "0.0.10", @@ -182,7 +178,7 @@ "tsconfig-paths": "4.2.0", "typeorm": "0.3.27", "typescript": "5.9.3", - "ulid": "2.4.0", + "ulid": "3.0.1", "vary": "1.1.2", "web-push": "3.6.7", "ws": "8.18.3", @@ -190,28 +186,25 @@ }, "devDependencies": { "@jest/globals": "29.7.0", - "@nestjs/platform-express": "10.4.20", - "@sentry/vue": "10.23.0", + "@nestjs/platform-express": "11.1.9", + "@sentry/vue": "10.26.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.39", "@types/accepts": "1.3.7", - "@types/archiver": "6.0.4", - "@types/bcryptjs": "2.4.6", + "@types/archiver": "7.0.0", "@types/body-parser": "1.19.6", "@types/color-convert": "2.0.4", "@types/content-disposition": "0.5.9", "@types/fluent-ffmpeg": "2.1.28", - "@types/htmlescape": "1.1.3", "@types/http-link-header": "1.0.7", "@types/jest": "29.5.14", "@types/js-yaml": "4.0.9", - "@types/jsdom": "21.1.7", "@types/jsonld": "1.5.15", "@types/jsrsasign": "10.5.15", - "@types/mime-types": "2.1.4", - "@types/ms": "0.7.34", - "@types/node": "24.10.0", - "@types/nodemailer": "6.4.21", + "@types/mime-types": "3.0.1", + "@types/ms": "2.1.0", + "@types/node": "24.10.1", + "@types/nodemailer": "7.0.4", "@types/oauth": "0.9.6", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", @@ -224,24 +217,25 @@ "@types/sanitize-html": "2.16.0", "@types/semver": "7.7.1", "@types/simple-oauth2": "5.0.7", - "@types/sinonjs__fake-timers": "8.1.5", + "@types/sinonjs__fake-timers": "15.0.1", "@types/supertest": "6.0.3", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.46.3", - "@typescript-eslint/parser": "8.46.3", + "@typescript-eslint/eslint-plugin": "8.47.0", + "@typescript-eslint/parser": "8.47.0", "aws-sdk-client-mock": "4.1.0", - "cross-env": "7.0.3", + "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", - "execa": "8.0.1", - "fkill": "9.0.0", + "execa": "9.6.0", + "fkill": "10.0.1", "jest": "29.7.0", "jest-mock": "29.7.0", - "nodemon": "3.1.10", - "pid-port": "1.0.2", + "jest-util": "29.7.0", + "nodemon": "3.1.11", + "pid-port": "2.0.0", "simple-oauth2": "5.1.0", "supertest": "7.1.4" } diff --git a/packages/backend/src/@types/redis-lock.d.ts b/packages/backend/src/@types/redis-lock.d.ts deleted file mode 100644 index b037cde5ee..0000000000 --- a/packages/backend/src/@types/redis-lock.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -declare module 'redis-lock' { - import type Redis from 'ioredis'; - - type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise) => void; - function redisLock(client: Redis.Redis, retryDelay: number): Lock; - - export = redisLock; -} diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 5ec362fb34..4776d0d412 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -10,8 +10,6 @@ import * as os from 'node:os'; import cluster from 'node:cluster'; import chalk from 'chalk'; import chalkTemplate from 'chalk-template'; -import * as Sentry from '@sentry/node'; -import { nodeProfilingIntegration } from '@sentry/profiling-node'; import Logger from '@/logger.js'; import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; @@ -74,6 +72,9 @@ export async function masterMain() { bootLogger.succ('Misskey initialized'); if (config.sentryForBackend) { + const Sentry = await import('@sentry/node'); + const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); + Sentry.init({ integrations: [ ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index 5d4a15b29f..3feb6fd199 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -4,8 +4,6 @@ */ import cluster from 'node:cluster'; -import * as Sentry from '@sentry/node'; -import { nodeProfilingIntegration } from '@sentry/profiling-node'; import { envOption } from '@/env.js'; import { loadConfig } from '@/config.js'; import { jobQueue, server } from './common.js'; @@ -17,6 +15,9 @@ export async function workerMain() { const config = loadConfig(); if (config.sentryForBackend) { + const Sentry = await import('@sentry/node'); + const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); + Sentry.init({ integrations: [ ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts deleted file mode 100644 index bd2749cb87..0000000000 --- a/packages/backend/src/core/AppLockService.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promisify } from 'node:util'; -import { Inject, Injectable } from '@nestjs/common'; -import redisLock from 'redis-lock'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -/** - * Retry delay (ms) for lock acquisition - */ -const retryDelay = 100; - -@Injectable() -export class AppLockService { - private lock: (key: string, timeout?: number, _?: (() => Promise) | undefined) => Promise<() => void>; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - ) { - this.lock = promisify(redisLock(this.redisClient, retryDelay)); - } - - /** - * Get AP Object lock - * @param uri AP object ID - * @param timeout Lock timeout (ms), The timeout releases previous lock. - * @returns Unlock function - */ - @bindThis - public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`ap-object:${uri}`, timeout); - } - - @bindThis - public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`chart-insert:${lockKey}`, timeout); - } -} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 8c8d22c77d..87575ca59a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -21,7 +21,6 @@ import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; -import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; @@ -166,7 +165,6 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; -const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; @@ -320,7 +318,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AnnouncementService, AntennaService, - AppLockService, AchievementService, AvatarDecorationService, CaptchaService, @@ -470,7 +467,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AnnouncementService, $AntennaService, - $AppLockService, $AchievementService, $AvatarDecorationService, $CaptchaService, @@ -621,7 +617,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AnnouncementService, AntennaService, - AppLockService, AchievementService, AvatarDecorationService, CaptchaService, @@ -770,7 +765,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AnnouncementService, $AntennaService, - $AppLockService, $AchievementService, $AvatarDecorationService, $CaptchaService, diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index ce3af7c774..955f7035d7 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -5,9 +5,9 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; import tinycolor from 'tinycolor2'; import * as Redis from 'ioredis'; +import * as htmlParser from 'node-html-parser'; import type { MiInstance } from '@/models/Instance.js'; import type Logger from '@/logger.js'; import { DI } from '@/di-symbols.js'; @@ -15,7 +15,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import type { DOMWindow } from 'jsdom'; type NodeInfo = { openRegistrations?: unknown; @@ -59,7 +58,7 @@ export class FetchInstanceMetadataService { return await this.redisClient.set( `fetchInstanceMetadata:mutex:v2:${host}`, '1', 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 - 'GET' // 古い値を返す(なかったらnull) + 'GET', // 古い値を返す(なかったらnull) ); } @@ -181,15 +180,14 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchDom(instance: MiInstance): Promise { + private async fetchDom(instance: MiInstance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); const url = 'https://' + instance.host; const html = await this.httpRequestService.getHtml(url); - const { window } = new JSDOM(html); - const doc = window.document; + const doc = htmlParser.parse(html); return doc; } @@ -206,12 +204,12 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { + private async fetchFaviconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null): Promise { const url = 'https://' + instance.host; if (doc) { // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; + const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.attributes.rel === 'icon')?.attributes.href; if (href) { return (new URL(href, url)).href; @@ -232,7 +230,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { const url = 'https://' + instance.host; return (new URL(manifest.icons[0].src, url)).href; @@ -246,9 +244,9 @@ export class FetchInstanceMetadataService { // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 const href = [ - links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, - links.find(link => link.relList.contains('apple-touch-icon'))?.href, - links.find(link => link.relList.contains('icon'))?.href, + links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon-precomposed'))?.attributes.href, + links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon'))?.attributes.href, + links.find(link => link.attributes.rel?.split(/\s+/).includes('icon'))?.attributes.href, ] .find(href => href); @@ -261,7 +259,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getThemeColor(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; if (themeColor) { @@ -273,7 +271,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -298,7 +296,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getDescription(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 28d980f718..b9f1c62d9d 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -5,26 +5,19 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import * as parse5 from 'parse5'; -import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom'; +import * as htmlParser from 'node-html-parser'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; -import type { DefaultTreeAdapterMap } from 'parse5'; +import { escapeHtml } from '@/misc/escape-html.js'; import type * as mfm from 'mfm-js'; -const treeAdapter = parse5.defaultTreeAdapter; -type Node = DefaultTreeAdapterMap['node']; -type ChildNode = DefaultTreeAdapterMap['childNode']; - const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; -export type Appender = (document: Document, body: HTMLParagraphElement) => void; - @Injectable() export class MfmService { constructor( @@ -40,68 +33,68 @@ export class MfmService { const normalizedHashtagNames = hashtagNames == null ? undefined : new Set(hashtagNames.map(x => normalizeForSearch(x))); - const dom = parse5.parseFragment(html); + const doc = htmlParser.parse(`
${html}
`); let text = ''; - for (const n of dom.childNodes) { + for (const n of doc.childNodes) { analyze(n); } return text.trim(); - function getText(node: Node): string { - if (treeAdapter.isTextNode(node)) return node.value; - if (!treeAdapter.isElementNode(node)) return ''; - if (node.nodeName === 'br') return '\n'; + function getText(node: htmlParser.Node): string { + if (node instanceof htmlParser.TextNode) return node.textContent; + if (!(node instanceof htmlParser.HTMLElement)) return ''; + if (node.tagName === 'BR') return '\n'; - if (node.childNodes) { + if (node.childNodes != null) { return node.childNodes.map(n => getText(n)).join(''); } return ''; } - function appendChildren(childNodes: ChildNode[]): void { - if (childNodes) { + function analyzeChildren(childNodes: htmlParser.Node[] | null): void { + if (childNodes != null) { for (const n of childNodes) { analyze(n); } } } - function analyze(node: Node) { - if (treeAdapter.isTextNode(node)) { - text += node.value; + function analyze(node: htmlParser.Node) { + if (node instanceof htmlParser.TextNode) { + text += node.textContent; return; } // Skip comment or document type node - if (!treeAdapter.isElementNode(node)) { + if (!(node instanceof htmlParser.HTMLElement)) { return; } - switch (node.nodeName) { - case 'br': { + switch (node.tagName) { + case 'BR': { text += '\n'; break; } - case 'a': { + case 'A': { const txt = getText(node); - const rel = node.attrs.find(x => x.name === 'rel'); - const href = node.attrs.find(x => x.name === 'href'); + const rel = node.attributes.rel; + const href = node.attributes.href; // ハッシュタグ - if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { + if (normalizedHashtagNames && href != null && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { + } else if (txt.startsWith('@') && !(rel != null && rel.startsWith('me '))) { const part = txt.split('@'); if (part.length === 2 && href) { //#region ホスト名部分が省略されているので復元する - const acct = `${txt}@${(new URL(href.value)).hostname}`; + const acct = `${txt}@${(new URL(href)).hostname}`; text += acct; //#endregion } else if (part.length === 3) { @@ -116,17 +109,17 @@ export class MfmService { if (!href) { return txt; } - if (!txt || txt === href.value) { // #6383: Missing text node - if (href.value.match(urlRegexFull)) { - return href.value; + if (!txt || txt === href) { // #6383: Missing text node + if (href.match(urlRegexFull)) { + return href; } else { - return `<${href.value}>`; + return `<${href}>`; } } - if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { - return `[${txt}](<${href.value}>)`; // #6846 + if (href.match(urlRegex) && !href.match(urlRegexFull)) { + return `[${txt}](<${href}>)`; // #6846 } else { - return `[${txt}](${href.value})`; + return `[${txt}](${href})`; } }; @@ -135,60 +128,64 @@ export class MfmService { break; } - case 'h1': { + case 'H1': { text += '【'; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += '】\n'; break; } - case 'b': - case 'strong': { + case 'B': + case 'STRONG': { text += '**'; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += '**'; break; } - case 'small': { + case 'SMALL': { text += ''; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += ''; break; } - case 's': - case 'del': { + case 'S': + case 'DEL': { text += '~~'; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += '~~'; break; } - case 'i': - case 'em': { + case 'I': + case 'EM': { text += ''; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += ''; break; } - case 'ruby': { + case 'RUBY': { let ruby: [string, string][] = []; for (const child of node.childNodes) { - if (child.nodeName === 'rp') { + if ((child instanceof htmlParser.TextNode) && !/\s|\[|\]/.test(child.textContent)) { + ruby.push([child.textContent, '']); continue; } - if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) { - ruby.push([child.value, '']); + + if (!(child instanceof htmlParser.HTMLElement)) continue; + + if (child.tagName === 'RP') { continue; } - if (child.nodeName === 'rt' && ruby.length > 0) { + + if (child.tagName === 'RT' && ruby.length > 0) { const rt = getText(child); if (/\s|\[|\]/.test(rt)) { // If any space is included in rt, it is treated as a normal text ruby = []; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); break; } else { ruby.at(-1)![1] = rt; @@ -197,7 +194,7 @@ export class MfmService { } // If any other element is included in ruby, it is treated as a normal text ruby = []; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); break; } for (const [base, rt] of ruby) { @@ -207,26 +204,30 @@ export class MfmService { } // block code (
)
-				case 'pre': {
-					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+				case 'PRE': {
+					if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.HTMLElement) && node.childNodes[0].tagName === 'CODE') {
 						text += '\n```\n';
 						text += getText(node.childNodes[0]);
 						text += '\n```\n';
+					} else if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.TextNode) && node.childNodes[0].textContent.startsWith('') && node.childNodes[0].textContent.endsWith('')) {
+						text += '\n```\n';
+						text += node.childNodes[0].textContent.slice(6, -7);
+						text += '\n```\n';
 					} else {
-						appendChildren(node.childNodes);
+						analyzeChildren(node.childNodes);
 					}
 					break;
 				}
 
 				// inline code ()
-				case 'code': {
+				case 'CODE': {
 					text += '`';
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					text += '`';
 					break;
 				}
 
-				case 'blockquote': {
+				case 'BLOCKQUOTE': {
 					const t = getText(node);
 					if (t) {
 						text += '\n> ';
@@ -235,33 +236,33 @@ export class MfmService {
 					break;
 				}
 
-				case 'p':
-				case 'h2':
-				case 'h3':
-				case 'h4':
-				case 'h5':
-				case 'h6': {
+				case 'P':
+				case 'H2':
+				case 'H3':
+				case 'H4':
+				case 'H5':
+				case 'H6': {
 					text += '\n\n';
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					break;
 				}
 
 				// other block elements
-				case 'div':
-				case 'header':
-				case 'footer':
-				case 'article':
-				case 'li':
-				case 'dt':
-				case 'dd': {
+				case 'DIV':
+				case 'HEADER':
+				case 'FOOTER':
+				case 'ARTICLE':
+				case 'LI':
+				case 'DT':
+				case 'DD': {
 					text += '\n';
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					break;
 				}
 
 				default:	// includes inline elements
 				{
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					break;
 				}
 			}
@@ -269,52 +270,35 @@ export class MfmService {
 	}
 
 	@bindThis
-	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
+	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], extraHtml: string | null = null) {
 		if (nodes == null) {
 			return null;
 		}
 
-		const { happyDOM, window } = new Window();
-
-		const doc = window.document;
-
-		const body = doc.createElement('p');
-
-		function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
-			if (children) {
-				for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
-			}
+		function toHtml(children?: mfm.MfmNode[]): string {
+			if (children == null) return '';
+			return children.map(x => handlers[x.type](x)).join('');
 		}
 
 		function fnDefault(node: mfm.MfmFn) {
-			const el = doc.createElement('i');
-			appendChildren(node.children, el);
-			return el;
+			return `${toHtml(node.children)}`;
 		}
 
-		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+		const handlers = {
 			bold: (node) => {
-				const el = doc.createElement('b');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			small: (node) => {
-				const el = doc.createElement('small');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			strike: (node) => {
-				const el = doc.createElement('del');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			italic: (node) => {
-				const el = doc.createElement('i');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			fn: (node) => {
@@ -323,10 +307,7 @@ export class MfmService {
 						const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
 						try {
 							const date = new Date(parseInt(text, 10) * 1000);
-							const el = doc.createElement('time');
-							el.setAttribute('datetime', date.toISOString());
-							el.textContent = date.toISOString();
-							return el;
+							return ``;
 						} catch (err) {
 							return fnDefault(node);
 						}
@@ -336,21 +317,9 @@ export class MfmService {
 						if (node.children.length === 1) {
 							const child = node.children[0];
 							const text = child.type === 'text' ? child.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
 
-							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
-
-							rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
-							rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
-							return rubyEl;
+							// ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
+							return `${escapeHtml(text.split(' ')[0])}(${escapeHtml(text.split(' ')[1])})`;
 						} else {
 							const rt = node.children.at(-1);
 
@@ -359,21 +328,9 @@ export class MfmService {
 							}
 
 							const text = rt.type === 'text' ? rt.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
 
-							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
-
-							appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
-							rtEl.appendChild(doc.createTextNode(text.trim()));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
-							return rubyEl;
+							// ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
+							return `${toHtml(node.children.slice(0, node.children.length - 1))}(${escapeHtml(text.trim())})`;
 						}
 					}
 
@@ -384,125 +341,98 @@ export class MfmService {
 			},
 
 			blockCode: (node) => {
-				const pre = doc.createElement('pre');
-				const inner = doc.createElement('code');
-				inner.textContent = node.props.code;
-				pre.appendChild(inner);
-				return pre;
+				return `
${escapeHtml(node.props.code)}
`; }, center: (node) => { - const el = doc.createElement('div'); - appendChildren(node.children, el); - return el; + return `
${toHtml(node.children)}
`; }, emojiCode: (node) => { - return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); + return `\u200B:${escapeHtml(node.props.name)}:\u200B`; }, unicodeEmoji: (node) => { - return doc.createTextNode(node.props.emoji); + return node.props.emoji; }, hashtag: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); - a.textContent = `#${node.props.hashtag}`; - a.setAttribute('rel', 'tag'); - return a; + return ``; }, inlineCode: (node) => { - const el = doc.createElement('code'); - el.textContent = node.props.code; - return el; + return `${escapeHtml(node.props.code)}`; }, mathInline: (node) => { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; + return `${escapeHtml(node.props.formula)}`; }, mathBlock: (node) => { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; + return `
${escapeHtml(node.props.formula)}
`; }, link: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', node.props.url); - appendChildren(node.children, a); - return a; + try { + const url = new URL(node.props.url); + return `${toHtml(node.children)}`; + } catch (err) { + return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`; + } }, mention: (node) => { - const a = doc.createElement('a'); const { username, host, acct } = node.props; const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase()); - a.setAttribute('href', remoteUserInfo + const href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) - : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`); - a.className = 'u-url mention'; - a.textContent = acct; - return a; + : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`; + try { + const url = new URL(href); + return `${escapeHtml(acct)}`; + } catch (err) { + return escapeHtml(acct); + } }, quote: (node) => { - const el = doc.createElement('blockquote'); - appendChildren(node.children, el); - return el; + return `
${toHtml(node.children)}
`; }, text: (node) => { if (!node.props.text.match(/[\r\n]/)) { - return doc.createTextNode(node.props.text); + return escapeHtml(node.props.text); } - const el = doc.createElement('span'); - const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x)); + let html = ''; - for (const x of intersperse('br', nodes)) { - el.appendChild(x === 'br' ? doc.createElement('br') : x); + const lines = node.props.text.split(/\r\n|\r|\n/).map(x => escapeHtml(x)); + + for (const x of intersperse('br', lines)) { + html += x === 'br' ? '
' : x; } - return el; + return html; }, url: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', node.props.url); - a.textContent = node.props.url; - return a; + try { + const url = new URL(node.props.url); + return `${escapeHtml(node.props.url)}`; + } catch (err) { + return escapeHtml(node.props.url); + } }, search: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`); - a.textContent = node.props.content; - return a; + return `${escapeHtml(node.props.content)}`; }, plain: (node) => { - const el = doc.createElement('span'); - appendChildren(node.children, el); - return el; + return `${toHtml(node.children)}`; }, - }; + } satisfies { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => string } as { [K in mfm.MfmNode['type']]: (node: mfm.MfmNode) => string }; - appendChildren(nodes, body); - - for (const additionalAppender of additionalAppenders) { - additionalAppender(doc, body); - } - - // Remove the unnecessary namespace - const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*

/, '

'); - - happyDOM.close().catch(err => {}); - - return serialized; + return `${toHtml(nodes)}${extraHtml ?? ''}`; } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index eeade4569b..310ffec7ce 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -202,7 +202,7 @@ export class NotificationService implements OnApplicationShutdown { } // TODO - //const locales = await import('../../../../locales/index.js'); + //const locales = await import('i18n'); // TODO: locale ファイルをクライアント用とサーバー用で分けたい @@ -271,7 +271,7 @@ export class NotificationService implements OnApplicationShutdown { let untilTime = untilId ? this.toXListId(untilId) : null; let notifications: MiNotification[]; - for (;;) { + for (; ;) { let notificationsRes: [id: string, fields: string[]][]; // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index 372e1e2ab7..31c8d67c60 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -66,7 +66,6 @@ export class WebAuthnService { userID: isoUint8Array.fromUTF8String(userId), userName: userName, userDisplayName: userDisplayName, - attestationType: 'indirect', excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ id: key.id, transports: key.transports ?? undefined, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e88f60b806..81637580e3 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -14,8 +15,8 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { acquireApObjectLock } from '@/misc/distributed-lock.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; @@ -48,8 +49,8 @@ export class ApInboxService { @Inject(DI.config) private config: Config, - @Inject(DI.meta) - private meta: MiMeta, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -76,7 +77,6 @@ export class ApInboxService { private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, private noteDeleteService: NoteDeleteService, - private appLockService: AppLockService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, @@ -311,7 +311,7 @@ export class ApInboxService { // アナウンス先が許可されているかチェック if (!this.utilityService.isFederationAllowedUri(uri)) return; - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { // 既に同じURIを持つものが登録されていないかチェック @@ -438,7 +438,7 @@ export class ApInboxService { } } - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { const exist = await this.apNoteService.fetchNote(note); @@ -522,7 +522,7 @@ export class ApInboxService { private async deleteNote(actor: MiRemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { const note = await this.apDbResolverService.getNoteFromApId(uri); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index f4c07e472c..a928ed5ccf 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import * as mfm from 'mfm-js'; -import { MfmService, Appender } from '@/core/MfmService.js'; +import { MfmService } from '@/core/MfmService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { extractApHashtagObjects } from './models/tag.js'; @@ -25,17 +25,17 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: Pick, additionalAppender: Appender[] = []) { + public getNoteHtml(note: Pick, extraHtml: string | null = null) { let noMisskeyContent = false; const srcMfm = (note.text ?? ''); const parsed = mfm.parse(srcMfm); - if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { + if (extraHtml == null && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender); + const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), extraHtml); return { content, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 55521d6e3a..4570977c5d 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js'; import type { MiPoll } from '@/models/Poll.js'; import type { MiPollVote } from '@/models/PollVote.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { MfmService, type Appender } from '@/core/MfmService.js'; +import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js'; @@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { escapeHtml } from '@/misc/escape-html.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -384,7 +385,7 @@ export class ApRendererService { inReplyTo = null; } - let quote; + let quote: string | undefined; if (note.renoteId) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); @@ -430,29 +431,18 @@ export class ApRendererService { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - const apAppend: Appender[] = []; + let extraHtml: string | null = null; - if (quote) { + if (quote != null) { // Append quote link as `

RE: ...` - // the claas name `quote-inline` is used in non-misskey clients for styling quote notes. + // the class name `quote-inline` is used in non-misskey clients for styling quote notes. // For compatibility, the span part should be kept as possible. - apAppend.push((doc, body) => { - body.appendChild(doc.createElement('br')); - body.appendChild(doc.createElement('br')); - const span = doc.createElement('span'); - span.className = 'quote-inline'; - span.appendChild(doc.createTextNode('RE: ')); - const link = doc.createElement('a'); - link.setAttribute('href', quote); - link.textContent = quote; - span.appendChild(link); - body.appendChild(span); - }); + extraHtml = `

RE: ${escapeHtml(quote)}`; } const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend); + const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, extraHtml); const emojis = await this.getEmojis(note.emojis); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 61d328ccac..49298a1d22 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -6,7 +6,7 @@ import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { Window } from 'happy-dom'; +import * as htmlParser from 'node-html-parser'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -215,29 +215,9 @@ export class ApRequestService { _followAlternate === true ) { const html = await res.text(); - const { window, happyDOM } = new Window({ - settings: { - disableJavaScriptEvaluation: true, - disableJavaScriptFileLoading: true, - disableCSSFileLoading: true, - disableComputedStyleRendering: true, - handleDisabledFileLoadingAsSuccess: true, - navigation: { - disableMainFrameNavigation: true, - disableChildFrameNavigation: true, - disableChildPageNavigation: true, - disableFallbackToSetURL: true, - }, - timer: { - maxTimeout: 0, - maxIntervalTime: 0, - maxIntervalIterations: 0, - }, - }, - }); - const document = window.document; + try { - document.documentElement.innerHTML = html; + const document = htmlParser.parse(html); const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); if (alternate) { @@ -248,8 +228,6 @@ export class ApRequestService { } } catch (e) { // something went wrong parsing the HTML, ignore the whole thing - } finally { - happyDOM.close().catch(err => {}); } } //#endregion diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 8abacd293f..214d32f67f 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -5,14 +5,15 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; +import { acquireApObjectLock } from '@/misc/distributed-lock.js'; import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; import type { MiEmoji } from '@/models/Emoji.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import type Logger from '@/logger.js'; @@ -48,6 +49,9 @@ export class ApNoteService { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @@ -67,7 +71,6 @@ export class ApNoteService { private apMentionService: ApMentionService, private apImageService: ApImageService, private apQuestionService: ApQuestionService, - private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, private apDbResolverService: ApDbResolverService, @@ -354,7 +357,7 @@ export class ApNoteService { throw new StatusError('blocked host', 451); } - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { //#region このサーバーに既に登録されていたらそれを返す diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts index 05905f3782..7b9840af87 100644 --- a/packages/backend/src/core/chart/charts/active-users.ts +++ b/packages/backend/src/core/chart/charts/active-users.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/active-users.js'; @@ -28,11 +29,13 @@ export default class ActiveUsersChart extends Chart { // eslint-d @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, private idService: IdService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts index 04e771a95b..ed790de7b5 100644 --- a/packages/backend/src/core/chart/charts/ap-request.ts +++ b/packages/backend/src/core/chart/charts/ap-request.ts @@ -5,9 +5,10 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/ap-request.js'; @@ -22,10 +23,12 @@ export default class ApRequestChart extends Chart { // eslint-dis @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts index 613e074a9f..782873809a 100644 --- a/packages/backend/src/core/chart/charts/drive.ts +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/drive.js'; @@ -23,10 +24,12 @@ export default class DriveChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index c9b43cc66d..b7a7f640b8 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/federation.js'; @@ -26,16 +27,18 @@ export default class FederationChart extends Chart { // eslint-di @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts index 97f3bc6f2b..b1657e0a0b 100644 --- a/packages/backend/src/core/chart/charts/instance.ts +++ b/packages/backend/src/core/chart/charts/instance.ts @@ -5,13 +5,14 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/instance.js'; @@ -26,6 +27,9 @@ export default class InstanceChart extends Chart { // eslint-disa @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -39,10 +43,9 @@ export default class InstanceChart extends Chart { // eslint-disa private followingsRepository: FollowingsRepository, private utilityService: UtilityService, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts index f763b5fffa..aa64e2329a 100644 --- a/packages/backend/src/core/chart/charts/notes.ts +++ b/packages/backend/src/core/chart/charts/notes.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { NotesRepository } from '@/models/_.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/notes.js'; @@ -24,13 +25,15 @@ export default class NotesChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts index 404964d8b7..f7e92aecea 100644 --- a/packages/backend/src/core/chart/charts/per-user-drive.ts +++ b/packages/backend/src/core/chart/charts/per-user-drive.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { DriveFilesRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-drive.js'; @@ -25,14 +26,16 @@ export default class PerUserDriveChart extends Chart { // eslint- @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private appLockService: AppLockService, private driveFileEntityService: DriveFileEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts index 588ac638de..ea431a5131 100644 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FollowingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-following.js'; @@ -25,14 +26,16 @@ export default class PerUserFollowingChart extends Chart { // esl @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index e4900772bb..824d60042d 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import type { NotesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-notes.js'; @@ -25,13 +26,15 @@ export default class PerUserNotesChart extends Chart { // eslint- @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts index 31708fefa8..b3e1b2cea1 100644 --- a/packages/backend/src/core/chart/charts/per-user-pv.ts +++ b/packages/backend/src/core/chart/charts/per-user-pv.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-pv.js'; @@ -23,10 +24,12 @@ export default class PerUserPvChart extends Chart { // eslint-dis @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts index c29c4d2870..7bc1d9e7fa 100644 --- a/packages/backend/src/core/chart/charts/per-user-reactions.ts +++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-reactions.js'; @@ -25,11 +26,13 @@ export default class PerUserReactionsChart extends Chart { // esl @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts index 7a2844f4ed..8dd1a5d996 100644 --- a/packages/backend/src/core/chart/charts/test-grouped.ts +++ b/packages/backend/src/core/chart/charts/test-grouped.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-grouped.js'; import type { KVs } from '../core.js'; @@ -24,10 +25,12 @@ export default class TestGroupedChart extends Chart { // eslint-d @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts index b8d0556c9f..23b8649cce 100644 --- a/packages/backend/src/core/chart/charts/test-intersection.ts +++ b/packages/backend/src/core/chart/charts/test-intersection.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-intersection.js'; import type { KVs } from '../core.js'; @@ -22,10 +23,12 @@ export default class TestIntersectionChart extends Chart { // esl @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts index f94e008059..b84dd419ba 100644 --- a/packages/backend/src/core/chart/charts/test-unique.ts +++ b/packages/backend/src/core/chart/charts/test-unique.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-unique.js'; import type { KVs } from '../core.js'; @@ -22,10 +23,12 @@ export default class TestUniqueChart extends Chart { // eslint-di @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts index a90dc8f99b..0e95ce9239 100644 --- a/packages/backend/src/core/chart/charts/test.ts +++ b/packages/backend/src/core/chart/charts/test.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test.js'; import type { KVs } from '../core.js'; @@ -24,10 +25,12 @@ export default class TestChart extends Chart { // eslint-disable- @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts index d148fc629b..4471c1df23 100644 --- a/packages/backend/src/core/chart/charts/users.ts +++ b/packages/backend/src/core/chart/charts/users.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/users.js'; @@ -25,14 +26,16 @@ export default class UsersChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/misc/distributed-lock.ts b/packages/backend/src/misc/distributed-lock.ts new file mode 100644 index 0000000000..93bd741f62 --- /dev/null +++ b/packages/backend/src/misc/distributed-lock.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Redis from 'ioredis'; + +export async function acquireDistributedLock( + redis: Redis.Redis, + name: string, + timeout: number, + maxRetries: number, + retryInterval: number, +): Promise<() => Promise> { + const lockKey = `lock:${name}`; + const identifier = Math.random().toString(36).slice(2); + + let retries = 0; + while (retries < maxRetries) { + const result = await redis.set(lockKey, identifier, 'PX', timeout, 'NX'); + if (result === 'OK') { + return async () => { + const currentIdentifier = await redis.get(lockKey); + if (currentIdentifier === identifier) { + await redis.del(lockKey); + } + }; + } + + await new Promise(resolve => setTimeout(resolve, retryInterval)); + retries++; + } + + throw new Error(`Failed to acquire lock ${name}`); +} + +export function acquireApObjectLock( + redis: Redis.Redis, + uri: string, +): Promise<() => Promise> { + return acquireDistributedLock(redis, `ap-object:${uri}`, 30 * 1000, 50, 100); +} + +export function acquireChartInsertLock( + redis: Redis.Redis, + name: string, +): Promise<() => Promise> { + return acquireDistributedLock(redis, `chart-insert:${name}`, 30 * 1000, 50, 500); +} diff --git a/packages/backend/src/misc/escape-html.ts b/packages/backend/src/misc/escape-html.ts new file mode 100644 index 0000000000..819aeeed52 --- /dev/null +++ b/packages/backend/src/misc/escape-html.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 642d3fc8ad..306fdb41f6 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -5,7 +5,6 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Bull from 'bullmq'; -import * as Sentry from '@sentry/node'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; @@ -157,6 +156,13 @@ export class QueueProcessorService implements OnApplicationShutdown { }; } + let Sentry: typeof import('@sentry/node') | undefined; + if (Sentry != null) { + import('@sentry/node').then((mod) => { + Sentry = mod; + }); + } + //#region system { const processer = (job: Bull.Job) => { @@ -175,7 +181,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -192,7 +198,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -232,7 +238,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -249,7 +255,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -264,7 +270,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region deliver { this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job)); } else { return this.deliverProcessorService.process(job); @@ -289,7 +295,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -304,7 +310,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region inbox { this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job)); } else { return this.inboxProcessorService.process(job); @@ -329,7 +335,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -344,7 +350,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region user-webhook deliver { this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job)); } else { return this.userWebhookDeliverProcessorService.process(job); @@ -369,7 +375,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -384,7 +390,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system-webhook deliver { this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job)); } else { return this.systemWebhookDeliverProcessorService.process(job); @@ -409,7 +415,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -434,7 +440,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -456,7 +462,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -479,7 +485,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -497,7 +503,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -512,7 +518,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region ended poll notification { this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job)); } else { return this.endedPollNotificationProcessorService.process(job); @@ -527,7 +533,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region post scheduled note { this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job)); } else { return this.postScheduledNoteProcessorService.process(job); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 7a4af407a3..27c79ab438 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; import { Inject, Injectable } from '@nestjs/common'; -import * as Sentry from '@sentry/node'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -37,6 +36,7 @@ export class ApiCallService implements OnApplicationShutdown { private logger: Logger; private userIpHistories: Map>; private userIpHistoriesClearIntervalId: NodeJS.Timeout; + private Sentry: typeof import('@sentry/node') | null = null; constructor( @Inject(DI.meta) @@ -59,6 +59,12 @@ export class ApiCallService implements OnApplicationShutdown { this.userIpHistoriesClearIntervalId = setInterval(() => { this.userIpHistories.clear(); }, 1000 * 60 * 60); + + if (this.config.sentryForBackend) { + import('@sentry/node').then((Sentry) => { + this.Sentry = Sentry; + }); + } } #sendApiError(reply: FastifyReply, err: ApiError): void { @@ -120,8 +126,8 @@ export class ApiCallService implements OnApplicationShutdown { }, }); - if (this.config.sentryForBackend) { - Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + if (this.Sentry != null) { + this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { level: 'error', user: { id: userId, @@ -432,8 +438,8 @@ export class ApiCallService implements OnApplicationShutdown { } // API invoking - if (this.config.sentryForBackend) { - return await Sentry.startSpan({ + if (this.Sentry != null) { + return await this.Sentry.startSpan({ name: 'API: ' + ep.name, }, () => ep.exec(data, user, token, file, request.ip, request.headers) .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts index 057b567312..44719592d1 100644 --- a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts +++ b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import type { ClipFavoritesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; @@ -31,11 +30,6 @@ export const meta = { export const paramDef = { type: 'object', properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - sinceDate: { type: 'integer' }, - untilDate: { type: 'integer' }, }, required: [], } as const; @@ -46,16 +40,14 @@ export default class extends Endpoint { // eslint- @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, - private queryService: QueryService, private clipEntityService: ClipEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.clipFavoritesRepository.createQueryBuilder('favorite'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + const query = this.clipFavoritesRepository.createQueryBuilder('favorite') .andWhere('favorite.userId = :meId', { meId: me.id }) .leftJoinAndSelect('favorite.clip', 'clip'); const favorites = await query - .limit(ps.limit) .getMany(); return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 113a09cb14..9971a1ea4d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -7,7 +7,7 @@ import RE2 from 're2'; import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import { JSDOM } from 'jsdom'; +import * as htmlParser from 'node-html-parser'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; @@ -569,16 +569,15 @@ export default class extends Endpoint { // eslint- try { const html = await this.httpRequestService.getHtml(url); - const { window } = new JSDOM(html); - const doc: Document = window.document; + const doc = htmlParser.parse(html); const myLink = `${this.config.url}/@${user.username}`; const aEls = Array.from(doc.getElementsByTagName('a')); const linkEls = Array.from(doc.getElementsByTagName('link')); - const includesMyLink = aEls.some(a => a.href === myLink); - const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); + const includesMyLink = aEls.some(a => a.attributes.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.attributes.rel?.split(/\s+/).includes('me') && link.attributes.href === myLink); if (includesMyLink || includesRelMeLinks) { await this.userProfilesRepository.createQueryBuilder('profile').update() @@ -588,8 +587,6 @@ export default class extends Endpoint { // eslint- }) .execute(); } - - window.close(); } catch (err) { // なにもしない } diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts index 880f4964a1..efb5ee01d1 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts @@ -135,6 +135,18 @@ export const meta = { code: 'CANNOT_RENOTE_TO_EXTERNAL', id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', }, + + scheduledAtRequired: { + message: 'scheduledAt is required when isActuallyScheduled is true.', + code: 'SCHEDULED_AT_REQUIRED', + id: '15e28a55-e74c-4d65-89b7-8880cdaaa87d', + }, + + scheduledAtMustBeInFuture: { + message: 'scheduledAt must be in the future.', + code: 'SCHEDULED_AT_MUST_BE_IN_FUTURE', + id: 'e4bed6c9-017e-4934-aed0-01c22cc60ec1', + }, }, limit: { @@ -252,6 +264,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); case 'c3275f19-4558-4c59-83e1-4f684b5fab66': throw new ApiError(meta.errors.tooManyScheduledNotes); + case '94a89a43-3591-400a-9c17-dd166e71fdfa': + throw new ApiError(meta.errors.scheduledAtRequired); + case 'b34d0c1b-996f-4e34-a428-c636d98df457': + throw new ApiError(meta.errors.scheduledAtMustBeInFuture); default: throw err; } diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts index 9a2e2ca415..2900e0cb0d 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts @@ -165,6 +165,18 @@ export const meta = { code: 'TOO_MANY_SCHEDULED_NOTES', id: '02f5df79-08ae-4a33-8524-f1503c8f6212', }, + + scheduledAtRequired: { + message: 'scheduledAt is required when isActuallyScheduled is true.', + code: 'SCHEDULED_AT_REQUIRED', + id: 'fe9737d5-cc41-498c-af9d-149207307530', + }, + + scheduledAtMustBeInFuture: { + message: 'scheduledAt must be in the future.', + code: 'SCHEDULED_AT_MUST_BE_IN_FUTURE', + id: 'ed1a6673-d0d1-4364-aaae-9bf3f139cbc5', + }, }, limit: { @@ -295,6 +307,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.containsTooManyMentions); case 'bacdf856-5c51-4159-b88a-804fa5103be5': throw new ApiError(meta.errors.tooManyScheduledNotes); + case '94a89a43-3591-400a-9c17-dd166e71fdfa': + throw new ApiError(meta.errors.scheduledAtRequired); + case 'b34d0c1b-996f-4e34-a428-c636d98df457': + throw new ApiError(meta.errors.scheduledAtMustBeInFuture); default: throw err; } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index cdd7102666..2b0b303b98 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -6,7 +6,7 @@ import dns from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; +import * as htmlParser from 'node-html-parser'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; @@ -120,9 +120,9 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt } const text = await res.text(); - const fragment = JSDOM.fragment(text); + const fragment = htmlParser.parse(`

${text}
`); - redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href)); + redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); let name = id; let logo: string | null = null; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index f9d904f3cd..fef6a27087 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -15,7 +15,6 @@ import fastifyStatic from '@fastify/static'; import fastifyView from '@fastify/view'; import fastifyProxy from '@fastify/http-proxy'; import vary from 'vary'; -import htmlSafeJsonStringify from 'htmlescape'; import type { Config } from '@/config.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; @@ -63,6 +62,20 @@ const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; const tarball = `${_dirname}/../../../../../built/tarball/`; +const ESCAPE_LOOKUP = { + '&': '\\u0026', + '>': '\\u003e', + '<': '\\u003c', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +} as Record; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +function htmlSafeJsonStringify(obj: any): string { + return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]); +} + @Injectable() export class ClientServerService { private logger: Logger; diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index fec83c2433..fe9a217ee8 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -506,10 +506,10 @@ describe('クリップ', () => { }); }; - const myFavorites = async (parameters: Misskey.entities.ClipsMyFavoritesRequest, request: Partial> = {}): Promise => { + const myFavorites = async (request: Partial> = {}): Promise => { return successfulApiCall({ endpoint: 'clips/my-favorites', - parameters, + parameters: {}, user: alice, ...request, }); @@ -562,9 +562,8 @@ describe('クリップ', () => { await favorite({ clipId: clip.id }); } - const favorited = await myFavorites({ - limit: 30, - }); + // pagenationはない。全部一気にとれる。 + const favorited = await myFavorites(); assert.strictEqual(favorited.length, clips.length); for (const clip of favorited) { assert.strictEqual(clip.favoritedCount, 1); @@ -618,7 +617,7 @@ describe('クリップ', () => { const clip = await show({ clipId: aliceClip.id }); assert.strictEqual(clip.favoritedCount, 0); assert.strictEqual(clip.isFavorited, false); - assert.deepStrictEqual(await myFavorites({}), []); + assert.deepStrictEqual(await myFavorites(), []); }); test.each([ @@ -652,13 +651,13 @@ describe('クリップ', () => { test('を取得できる。', async () => { await favorite({ clipId: aliceClip.id }); - const favorited = await myFavorites({}); + const favorited = await myFavorites(); assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); }); test('を取得したとき他人のお気に入りは含まない。', async () => { await favorite({ clipId: aliceClip.id }); - const favorited = await myFavorites({}, { user: bob }); + const favorited = await myFavorites({ user: bob }); assert.deepStrictEqual(favorited, []); }); }); diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts index 4bcecc9716..1a703b3d36 100644 --- a/packages/backend/test/e2e/exports.ts +++ b/packages/backend/test/e2e/exports.ts @@ -16,7 +16,7 @@ describe('export-clips', () => { let bob: misskey.entities.SignupResponse; // XXX: Any better way to get the result? - async function pollFirstDriveFile() { + async function pollFirstDriveFile(): Promise { while (true) { const files = (await api('drive/files', {}, alice)).body; if (!files.length) { diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index bef98893c6..f00843de10 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -73,7 +73,7 @@ describe('Webリソース', () => { }; const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { - return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content; + return res.body.querySelector('meta[' + superkey + '="' + key + '"]')?.attributes.content; }; beforeAll(async () => { diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index f639f90ea6..96a6311a5a 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -19,7 +19,7 @@ import { ResourceOwnerPassword, } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; -import { JSDOM } from 'jsdom'; +import * as htmlParser from 'node-html-parser'; import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; import { api, port, sendEnvUpdateRequest, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; @@ -73,11 +73,11 @@ const clientConfig: ModuleOptions<'client_id'> = { }; function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } { - const fragment = JSDOM.fragment(html); + const doc = htmlParser.parse(`
${html}
`); return { - transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, - clientName: fragment.querySelector('meta[name="misskey:oauth:client-name"]')?.content, - clientLogo: fragment.querySelector('meta[name="misskey:oauth:client-logo"]')?.content, + transactionId: doc.querySelector('meta[name="misskey:oauth:transaction-id"]')?.attributes.content, + clientName: doc.querySelector('meta[name="misskey:oauth:client-name"]')?.attributes.content, + clientLogo: doc.querySelector('meta[name="misskey:oauth:client-logo"]')?.attributes.content, }; } @@ -148,7 +148,7 @@ function assertIndirectError(response: Response, error: string): void { async function assertDirectError(response: Response, status: number, error: string): Promise { assert.strictEqual(response.status, status); - const data = await response.json(); + const data = await response.json() as any; assert.strictEqual(data.error, error); } @@ -704,7 +704,7 @@ describe('OAuth', () => { const response = await fetch(new URL('.well-known/oauth-authorization-server', host)); assert.strictEqual(response.status, 200); - const body = await response.json(); + const body = await response.json() as any; assert.strictEqual(body.issuer, 'http://misskey.local'); assert.ok(body.scopes_supported.includes('write:notes')); }); diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts index 7c6dd6a55f..9185f58acb 100644 --- a/packages/backend/test/jest.setup.ts +++ b/packages/backend/test/jest.setup.ts @@ -9,3 +9,4 @@ beforeAll(async () => { await initTestDb(false); await sendEnvResetRequest(); }); + diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 0b24f109f8..b3f7f426fe 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -26,7 +26,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); @@ -84,7 +84,7 @@ describe('AnnouncementService', () => { log: jest.fn(), }; } else if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/ApMfmService.ts b/packages/backend/test/unit/ApMfmService.ts index e81a321c9b..93efa5d7d3 100644 --- a/packages/backend/test/unit/ApMfmService.ts +++ b/packages/backend/test/unit/ApMfmService.ts @@ -9,7 +9,6 @@ import { Test } from '@nestjs/testing'; import { CoreModule } from '@/core/CoreModule.js'; import { ApMfmService } from '@/core/activitypub/ApMfmService.js'; import { GlobalModule } from '@/GlobalModule.js'; -import { MiNote } from '@/models/Note.js'; describe('ApMfmService', () => { let apMfmService: ApMfmService; @@ -31,7 +30,7 @@ describe('ApMfmService', () => { const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); assert.equal(noMisskeyContent, true, 'noMisskeyContent'); - assert.equal(content, '

テキスト @mention 🍊 ​:emoji:​ https://example.com

', 'content'); + assert.equal(content, 'テキスト @mention 🍊 ​:emoji:​ https://example.com', 'content'); }); test('Provide _misskey_content for MFM', () => { @@ -43,7 +42,7 @@ describe('ApMfmService', () => { const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); assert.equal(noMisskeyContent, false, 'noMisskeyContent'); - assert.equal(content, '

foo

', 'content'); + assert.equal(content, 'foo', 'content'); }); }); }); diff --git a/packages/backend/test/unit/CaptchaService.ts b/packages/backend/test/unit/CaptchaService.ts index 51b70b05a1..24bb81118e 100644 --- a/packages/backend/test/unit/CaptchaService.ts +++ b/packages/backend/test/unit/CaptchaService.ts @@ -446,7 +446,7 @@ describe('CaptchaService', () => { if (!res.success) { expect(res.error.code).toBe(code); } - expect(metaService.update).not.toBeCalled(); + expect(metaService.update).not.toHaveBeenCalled(); } describe('invalidParameters', () => { diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts index 964c65ccaa..48b108fbba 100644 --- a/packages/backend/test/unit/DriveService.ts +++ b/packages/backend/test/unit/DriveService.ts @@ -53,7 +53,7 @@ describe('DriveService', () => { s3Mock.on(DeleteObjectCommand) .rejects(new InvalidObjectState({ $metadata: {}, message: '' })); - await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error); + await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrow(Error); }); test('delete a file with no valid key', async () => { diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index 29bd03a201..28a2a971f4 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -17,7 +17,7 @@ import { FileInfo, FileInfoService } from '@/core/FileInfoService.js'; import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -34,7 +34,7 @@ describe('FileInfoService', () => { delete fi.sensitive; delete fi.blurhash; delete fi.porn; - + return fi; } @@ -54,7 +54,7 @@ describe('FileInfoService', () => { // return { }; //} if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index 7350da3cae..2f5f3745de 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -24,25 +24,25 @@ describe('MfmService', () => { describe('toHtml', () => { test('br', () => { const input = 'foo\nbar\nbaz'; - const output = '

foo
bar
baz

'; + const output = 'foo
bar
baz'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); test('br alt', () => { const input = 'foo\r\nbar\rbaz'; - const output = '

foo
bar
baz

'; + const output = 'foo
bar
baz'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); test('Do not generate unnecessary span', () => { const input = 'foo $[tada bar]'; - const output = '

foo bar

'; + const output = 'foo bar'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); test('escape', () => { const input = '```\n

Hello, world!

\n```'; - const output = '

<p>Hello, world!</p>

'; + const output = '
<p>Hello, world!</p>
'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); }); @@ -118,7 +118,7 @@ describe('MfmService', () => { assert.deepStrictEqual(mfmService.fromHtml('

a Misskey(ミス キー) b c

'), 'a Misskey(ミス キー) b c'); assert.deepStrictEqual( mfmService.fromHtml('

a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b

'), - 'a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b' + 'a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b', ); }); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 074430dd31..bee580d0c7 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -9,7 +9,7 @@ import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; import { ModuleMocker } from 'jest-mock'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { IdService } from '@/core/IdService.js'; @@ -45,7 +45,7 @@ describe('RelayService', () => { return { deliver: jest.fn() }; } if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 71090c8be6..9b17b1fbb9 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -11,7 +11,7 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { @@ -104,6 +104,8 @@ describe('RoleService', () => { beforeEach(async () => { clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], now: new Date(), shouldClearNativeTimers: true, }); @@ -135,7 +137,7 @@ describe('RoleService', () => { return { fetch: jest.fn() }; } if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts index 151f3b826a..6e7e5a8b59 100644 --- a/packages/backend/test/unit/S3Service.ts +++ b/packages/backend/test/unit/S3Service.ts @@ -72,7 +72,7 @@ describe('S3Service', () => { Bucket: 'fake', Key: 'fake', Body: 'x', - })).rejects.toThrowError(Error); + })).rejects.toThrow(Error); }); test('upload a large file error', async () => { @@ -82,7 +82,7 @@ describe('S3Service', () => { Bucket: 'fake', Key: 'fake', Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ - })).rejects.toThrowError(Error); + })).rejects.toThrow(Error); }); }); }); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index 0687ed8437..8ef46024ac 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -9,7 +9,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import { HttpHeader } from 'fastify/types/utils.js'; -import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; +import { MockMetadata, ModuleMocker } from 'jest-mock'; import { MiUser } from '@/models/User.js'; import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; @@ -95,7 +95,7 @@ describe('SigninWithPasskeyApiService', () => { ], }).useMocker((token) => { if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 9dedd3a79d..364a2c2fbd 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -9,6 +9,7 @@ import * as assert from 'assert'; import { jest } from '@jest/globals'; import * as lolex from '@sinonjs/fake-timers'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import TestChart from '@/core/chart/charts/test.js'; import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; import TestUniqueChart from '@/core/chart/charts/test-unique.js'; @@ -18,16 +19,16 @@ import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/t import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js'; import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; import { loadConfig } from '@/config.js'; -import type { AppLockService } from '@/core/AppLockService.js'; import Logger from '@/logger.js'; describe('Chart', () => { const config = loadConfig(); - const appLockService = { - getChartInsertLock: () => () => Promise.resolve(() => {}), - } as unknown as jest.Mocked; let db: DataSource | undefined; + let redisClient = { + set: () => Promise.resolve('OK'), + get: () => Promise.resolve(null), + } as unknown as jest.Mocked; let testChart: TestChart; let testGroupedChart: TestGroupedChart; @@ -64,12 +65,14 @@ describe('Chart', () => { await db.initialize(); const logger = new Logger('chart'); // TODO: モックにする - testChart = new TestChart(db, appLockService, logger); - testGroupedChart = new TestGroupedChart(db, appLockService, logger); - testUniqueChart = new TestUniqueChart(db, appLockService, logger); - testIntersectionChart = new TestIntersectionChart(db, appLockService, logger); + testChart = new TestChart(db, redisClient, logger); + testGroupedChart = new TestGroupedChart(db, redisClient, logger); + testUniqueChart = new TestUniqueChart(db, redisClient, logger); + testIntersectionChart = new TestIntersectionChart(db, redisClient, logger); clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)), shouldClearNativeTimers: true, }); diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts index 211846eef2..01a36c9fef 100644 --- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -141,6 +141,8 @@ describe('CheckModeratorsActivityProcessorService', () => { beforeEach(async () => { clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], now: new Date(baseDate), shouldClearNativeTimers: true, }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index daae7b9643..ecca28b5af 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -10,8 +10,8 @@ import { randomUUID } from 'node:crypto'; import { inspect } from 'node:util'; import WebSocket, { ClientOptions } from 'ws'; import fetch, { File, RequestInit, type Headers } from 'node-fetch'; +import * as htmlParser from 'node-html-parser'; import { DataSource } from 'typeorm'; -import { JSDOM } from 'jsdom'; import { type Response } from 'node-fetch'; import Fastify from 'fastify'; import { entities } from '../src/postgres.js'; @@ -468,7 +468,7 @@ export function makeStreamCatcher( export type SimpleGetResponse = { status: number, - body: any | JSDOM | null, + body: any | null, type: string | null, location: string | null }; @@ -499,7 +499,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde const body = jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : - htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? htmlParser.parse(await res.text()) : await bodyExtractor(res); return { diff --git a/packages/frontend-builder/locale-inliner.ts b/packages/frontend-builder/locale-inliner.ts index 9bef465eeb..191d7250a6 100644 --- a/packages/frontend-builder/locale-inliner.ts +++ b/packages/frontend-builder/locale-inliner.ts @@ -10,7 +10,7 @@ import { collectModifications } from './locale-inliner/collect-modifications.js' import { applyWithLocale } from './locale-inliner/apply-with-locale.js'; import { blankLogger } from './logger.js'; import type { Logger } from './logger.js'; -import type { Locale } from '../../locales/index.js'; +import type { Locale } from 'i18n'; import type { Manifest as ViteManifest } from 'vite'; export class LocaleInliner { diff --git a/packages/frontend-builder/locale-inliner/apply-with-locale.ts b/packages/frontend-builder/locale-inliner/apply-with-locale.ts index 5e601cdf12..78851d3029 100644 --- a/packages/frontend-builder/locale-inliner/apply-with-locale.ts +++ b/packages/frontend-builder/locale-inliner/apply-with-locale.ts @@ -5,7 +5,7 @@ import MagicString from 'magic-string'; import { assertNever } from '../utils.js'; -import type { Locale, ILocale } from '../../../locales/index.js'; +import type { ILocale, Locale } from 'i18n'; import type { TextModification } from '../locale-inliner.js'; import type { Logger } from '../logger.js'; diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index ef5c8e0367..37dd133fe6 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -18,8 +18,9 @@ "typescript": "5.9.3" }, "dependencies": { + "i18n": "workspace:*", "estree-walker": "3.0.3", "magic-string": "0.30.21", - "vite": "7.2.2" + "vite": "7.2.4" } } diff --git a/packages/frontend-embed/build.ts b/packages/frontend-embed/build.ts index 737233a4d0..4e1f588802 100644 --- a/packages/frontend-embed/build.ts +++ b/packages/frontend-embed/build.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import url from 'node:url'; import path from 'node:path'; import { execa } from 'execa'; -import locales from '../../locales/index.js'; +import locales from 'i18n'; import { LocaleInliner } from '../frontend-builder/locale-inliner.js' import { createLogger } from '../frontend-builder/logger'; diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 7bfd32686c..c27583cf86 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@discordapp/twemoji": "16.0.1", + "i18n": "workspace:*", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", "@rollup/pluginutils": "5.3.0", @@ -27,14 +28,14 @@ "misskey-js": "workspace:*", "punycode.js": "2.3.1", "rollup": "4.53.3", - "sass": "1.94.1", + "sass": "1.94.2", "shiki": "3.15.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.16", "tsconfig-paths": "4.2.0", "typescript": "5.9.3", "uuid": "13.0.0", - "vite": "7.2.2", + "vite": "7.2.4", "vue": "3.5.24" }, "devDependencies": { @@ -49,12 +50,12 @@ "@types/ws": "8.18.1", "@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/parser": "8.47.0", - "@vitest/coverage-v8": "3.2.4", + "@vitest/coverage-v8": "4.0.13", "@vue/runtime-core": "3.5.24", "acorn": "8.15.0", "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-vue": "10.5.1", + "eslint-plugin-vue": "10.6.0", "fast-glob": "3.3.3", "happy-dom": "20.0.10", "intersection-observer": "0.12.2", @@ -62,11 +63,11 @@ "msw": "2.12.2", "nodemon": "3.1.11", "prettier": "3.6.2", - "start-server-and-test": "2.1.2", + "start-server-and-test": "2.1.3", "tsx": "4.20.6", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "3.1.4", + "vue-component-type-helpers": "3.1.5", "vue-eslint-parser": "10.2.0", - "vue-tsc": "3.1.4" + "vue-tsc": "3.1.5" } } diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue index b621110ec9..9866e50958 100644 --- a/packages/frontend-embed/src/components/I18n.vue +++ b/packages/frontend-embed/src/components/I18n.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only