Merge branch 'develop' into minify-backend

This commit is contained in:
syuilo 2025-11-30 14:36:31 +09:00
commit ab5c38875d
126 changed files with 3245 additions and 3557 deletions

View File

@ -111,10 +111,5 @@ jobs:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- run: pnpm --filter misskey-js run build - run: pnpm --filter "${{ matrix.workspace }}^..." 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 typecheck - run: pnpm --filter ${{ matrix.workspace }} run typecheck

View File

@ -3,10 +3,12 @@ name: Lint
on: on:
push: push:
paths: paths:
- packages/i18n/**
- locales/** - locales/**
- .github/workflows/locale.yml - .github/workflows/locale.yml
pull_request: pull_request:
paths: paths:
- packages/i18n/**
- locales/** - locales/**
- .github/workflows/locale.yml - .github/workflows/locale.yml
jobs: jobs:
@ -22,7 +24,10 @@ jobs:
uses: pnpm/action-setup@v4.2.0 uses: pnpm/action-setup@v4.2.0
- uses: actions/setup-node@v4.4.0 - uses: actions/setup-node@v4.4.0
with: with:
node-version-file: '.node-version' node-version-file: ".node-version"
cache: 'pnpm' cache: "pnpm"
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- run: cd locales && node verify.js - run: pnpm --filter i18n build
- name: Verify Locales
working-directory: ./packages/i18n
run: pnpm run verify

View File

@ -1,8 +1,19 @@
## 2025.11.1 ## 2025.11.2
### General ### General
- -
### Client
-
### Server
- Enhance: メモリ使用量を削減しました
- Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上
- Enhance: 依存関係の更新
## 2025.11.1
### Client ### Client
- Enhance: リアクションの受け入れ設定にキャプションを追加 #15921 - Enhance: リアクションの受け入れ設定にキャプションを追加 #15921
@ -17,9 +28,10 @@
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1129) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1129)
- Fix: フォロー申請をキャンセルする際の確認ダイアログの文言が不正確な問題を修正 - Fix: フォロー申請をキャンセルする際の確認ダイアログの文言が不正確な問題を修正
- Fix: 初回読み込み時にエラーになることがある問題を修正 - Fix: 初回読み込み時にエラーになることがある問題を修正
- Fix: お気に入りクリップの一覧表示が正しく動作しない問題を修正
- Fix: AiScript Misskey 拡張APIにおいて、各種関数の引数で明示的に `null` が指定されている場合のハンドリングを修正
### Server ### Server
- Enhance: `clips/my-favorites` APIがページネーションに対応しました
- Enhance: メモリ使用量を削減しました - Enhance: メモリ使用量を削減しました
- Enhance: 依存関係の更新 - Enhance: 依存関係の更新
- Fix: ワードミュートの文字数計算を修正 - Fix: ワードミュートの文字数計算を修正

View File

@ -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/package.json", "./packages/frontend/"]
COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"]
COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"] 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/icons-subsetter/package.json", "./packages/icons-subsetter/"]
COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/sw/package.json", "./packages/sw/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] 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-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/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/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 --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis
COPY --chown=misskey:misskey . ./ COPY --chown=misskey:misskey . ./

View File

@ -83,6 +83,8 @@ files: "Fitxers"
download: "Descarregar" download: "Descarregar"
driveFileDeleteConfirm: "Estàs segur que vols suprimir el fitxer \"{name}\"? Les notes associades a aquest fitxer també seran esborrades." 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}?" 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." 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." importRequested: "Has sol·licitat una importació de dades. Això pot trigar una estona."
lists: "Llistes" lists: "Llistes"

View File

@ -83,6 +83,8 @@ files: "Archivos"
download: "Descargar" download: "Descargar"
driveFileDeleteConfirm: "¿Desea borrar el archivo \"{name}\"? Las notas que tengan este archivo como adjunto serán eliminadas" driveFileDeleteConfirm: "¿Desea borrar el archivo \"{name}\"? Las notas que tengan este archivo como adjunto serán eliminadas"
unfollowConfirm: "¿Desea dejar de seguir a {name}?" 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" 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." importRequested: "Has solicitado la importación. Puede llevar un tiempo."
lists: "Listas" lists: "Listas"
@ -126,7 +128,7 @@ pinnedNote: "Nota fijada"
pinned: "Fijar al perfil" pinned: "Fijar al perfil"
you: "Tú" you: "Tú"
clickToShow: "Haz clic para verlo" clickToShow: "Haz clic para verlo"
sensitive: "Marcado como sensible" sensitive: "Marcado como sensible (NSFW)"
add: "Agregar" add: "Agregar"
reaction: "Reacción" reaction: "Reacción"
reactions: "Reacciones" reactions: "Reacciones"
@ -141,7 +143,7 @@ rememberNoteVisibility: "Recordar visibilidad"
attachCancel: "Quitar adjunto" attachCancel: "Quitar adjunto"
deleteFile: "Eliminar archivo" deleteFile: "Eliminar archivo"
markAsSensitive: "Marcar como sensible" markAsSensitive: "Marcar como sensible"
unmarkAsSensitive: "Desmarcar como sensible" unmarkAsSensitive: "No marcar como sensible"
enterFileName: "Introduce el nombre del archivo" enterFileName: "Introduce el nombre del archivo"
mute: "Silenciar" mute: "Silenciar"
unmute: "Dejar de silenciar" unmute: "Dejar de silenciar"
@ -351,7 +353,7 @@ emptyFolder: "La carpeta está vacía"
dropHereToUpload: "Arrastra los archivos aquí para subirlos." dropHereToUpload: "Arrastra los archivos aquí para subirlos."
unableToDelete: "No se puede borrar" unableToDelete: "No se puede borrar"
inputNewFileName: "Ingrese un nuevo nombre de archivo" 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" inputNewFolderName: "Ingrese un nuevo nombre de la carpeta"
circularReferenceFolder: "La carpeta de destino es una sub-carpeta de la carpeta que quieres mover." 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." hasChildFilesOrFolders: "No se puede borrar esta carpeta. No está vacía."
@ -704,7 +706,7 @@ userSaysSomethingAbout: "{name} dijo algo sobre {word}"
makeActive: "Activar" makeActive: "Activar"
display: "Apariencia" display: "Apariencia"
copy: "Copiar" copy: "Copiar"
copiedToClipboard: "Texto copiado al portapapeles" copiedToClipboard: "Copiado al portapapeles"
metrics: "Métricas" metrics: "Métricas"
overview: "Resumen" overview: "Resumen"
logs: "Registros" logs: "Registros"
@ -713,7 +715,7 @@ database: "Base de datos"
channel: "Canal" channel: "Canal"
create: "Crear" create: "Crear"
notificationSetting: "Ajustes de Notificaciones" 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" useGlobalSetting: "Usar ajustes globales"
useGlobalSettingDesc: "Al activarse, se usará la configuración de notificaciones de la cuenta, al desactivarse se pueden hacer configuraciones particulares." useGlobalSettingDesc: "Al activarse, se usará la configuración de notificaciones de la cuenta, al desactivarse se pueden hacer configuraciones particulares."
other: "Otro" other: "Otro"
@ -745,7 +747,7 @@ system: "Sistema"
switchUi: "Cambiar interfaz de usuario" switchUi: "Cambiar interfaz de usuario"
desktop: "Escritorio" desktop: "Escritorio"
clip: "Clip" clip: "Clip"
createNew: "Crear" createNew: "Crear Nuevo"
optional: "Opcional" optional: "Opcional"
createNewClip: "Crear clip nuevo" createNewClip: "Crear clip nuevo"
unclip: "Quitar clip" unclip: "Quitar clip"
@ -1201,8 +1203,8 @@ iHaveReadXCarefullyAndAgree: "He leído el texto {x} y estoy de acuerdo"
dialog: "Diálogo" dialog: "Diálogo"
icon: "Avatar" icon: "Avatar"
forYou: "Para ti" forYou: "Para ti"
currentAnnouncements: "Anuncios actuales" currentAnnouncements: "Avisos actuales"
pastAnnouncements: "Anuncios anteriores" pastAnnouncements: "Avisos anteriores"
youHaveUnreadAnnouncements: "Hay anuncios sin leer" 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." useSecurityKey: "Por favor, sigue las instrucciones de tu dispositivo o navegador para usar tu clave de seguridad o tu clave de paso."
replies: "Responder" replies: "Responder"
@ -1410,8 +1412,8 @@ _imageEditing:
filename: "Nombre de archivo" filename: "Nombre de archivo"
filename_without_ext: "Nombre del archivo sin la extensión" filename_without_ext: "Nombre del archivo sin la extensión"
year: "Año de rodaje" year: "Año de rodaje"
month: "Mes de rodaje" month: "Mes de la fotografía"
day: "Día de rodaje" day: "Día de la fotografía"
hour: "Hora" hour: "Hora"
minute: "Minuto" minute: "Minuto"
second: "Segundo" second: "Segundo"
@ -1425,9 +1427,9 @@ _imageEditing:
gps_lat: "Latitud" gps_lat: "Latitud"
gps_long: "Longitud" gps_long: "Longitud"
_imageFrameEditor: _imageFrameEditor:
title: "Edición de Fotograma" title: "Edición de Fotos"
tip: "Decora tus imágenes con marcos y etiquetas que contengan metadatos." tip: "Decora tus imágenes con marcos y etiquetas que contengan metadatos."
header: "Cabezal" header: "Título"
footer: "Pie de página" footer: "Pie de página"
borderThickness: "Ancho del borde" borderThickness: "Ancho del borde"
labelThickness: "Ancho de la etiqueta" labelThickness: "Ancho de la etiqueta"
@ -1454,8 +1456,8 @@ _compression:
medium: "Tamaño mediano" medium: "Tamaño mediano"
small: "Tamaño pequeño" small: "Tamaño pequeño"
_order: _order:
newest: "Los más recientes primero" newest: "Más reciente primero"
oldest: "Los más antiguos primero" oldest: "Más antiguos primero"
_chat: _chat:
messages: "Mensajes" messages: "Mensajes"
noMessagesYet: "Aún no hay 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." 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." 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!" 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." 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: _cw:
title: "Alerta de contenido (CW)" title: "Alerta de contenido (CW)"
@ -2297,7 +2299,7 @@ _theme:
indicator: "Indicador" indicator: "Indicador"
panel: "Panel" panel: "Panel"
shadow: "Sombra" shadow: "Sombra"
header: "Cabezal" header: "Título"
navBg: "Fondo de la barra lateral" navBg: "Fondo de la barra lateral"
navFg: "Texto de la barra lateral" navFg: "Texto de la barra lateral"
navActive: "Texto de la barra lateral (activo)" navActive: "Texto de la barra lateral (activo)"
@ -2769,7 +2771,7 @@ _notification:
follow: "Siguiendo" follow: "Siguiendo"
mention: "Menciones" mention: "Menciones"
reply: "Respuestas" reply: "Respuestas"
renote: "Renotar" renote: "Renotas"
quote: "Citar" quote: "Citar"
reaction: "Reacción" reaction: "Reacción"
pollEnded: "La encuesta terminó" pollEnded: "La encuesta terminó"

View File

@ -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');
}

View File

@ -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();

View File

@ -83,6 +83,8 @@ files: "파일"
download: "다운로드" download: "다운로드"
driveFileDeleteConfirm: "{name} 파일을 삭제하시겠습니까? 이 파일을 사용하는 일부 콘텐츠도 삭제됩니다." driveFileDeleteConfirm: "{name} 파일을 삭제하시겠습니까? 이 파일을 사용하는 일부 콘텐츠도 삭제됩니다."
unfollowConfirm: "{name}님을 언팔로우하시겠습니까?" unfollowConfirm: "{name}님을 언팔로우하시겠습니까?"
cancelFollowRequestConfirm: "{name}(으)로의 팔로우 신청을 취소하시겠습니까?"
rejectFollowRequestConfirm: "{name}(으)로부터의 팔로우 신청을 거부하시겠습니까?"
exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다." exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다."
importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다." importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다."
lists: "리스트" lists: "리스트"

View File

@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@ -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);
}

View File

@ -83,6 +83,8 @@ files: "文件"
download: "下载" download: "下载"
driveFileDeleteConfirm: "要删除「{name}」文件吗?附加此文件的帖子也会被删除。" driveFileDeleteConfirm: "要删除「{name}」文件吗?附加此文件的帖子也会被删除。"
unfollowConfirm: "要取消对 {name} 的关注吗?" unfollowConfirm: "要取消对 {name} 的关注吗?"
cancelFollowRequestConfirm: "要取消申请关注{name}吗?"
rejectFollowRequestConfirm: "要拒绝{name}的关注申请吗?"
exportRequested: "导出请求已提交,这可能需要花一些时间,导出的文件将保存到网盘中。" exportRequested: "导出请求已提交,这可能需要花一些时间,导出的文件将保存到网盘中。"
importRequested: "导入请求已提交,这可能需要花一点时间。" importRequested: "导入请求已提交,这可能需要花一点时间。"
lists: "列表" lists: "列表"
@ -875,7 +877,7 @@ noInquiryUrlWarning: "尚未设置联络地址。"
noBotProtectionWarning: "尚未设置 Bot 防御。" noBotProtectionWarning: "尚未设置 Bot 防御。"
configure: "设置" configure: "设置"
postToGallery: "创建新图集" postToGallery: "创建新图集"
postToHashtag: "投稿到这个标签" postToHashtag: "发布至该话题"
gallery: "图集" gallery: "图集"
recentPosts: "最新发布" recentPosts: "最新发布"
popularPosts: "热门投稿" popularPosts: "热门投稿"
@ -2557,13 +2559,13 @@ _poll:
deadlineTime: "时间" deadlineTime: "时间"
duration: "期限" duration: "期限"
votesCount: "{n}票" votesCount: "{n}票"
totalVotes: "总票数 {n}" totalVotes: "总计{n}票"
vote: "投票" vote: "投票"
showResult: "显示结果" showResult: "查看结果"
voted: "已投票" voted: "已投票"
closed: "已截止" closed: "已截止"
remainingDays: "{d}天{h}小时后截止" remainingDays: "{d}天{h}小时后截止"
remainingHours: "{h} 小时 {m} 分后截止" remainingHours: "{h}小时{m}分后截止"
remainingMinutes: "{m}分{s}秒后截止" remainingMinutes: "{m}分{s}秒后截止"
remainingSeconds: "{s}秒后截止" remainingSeconds: "{s}秒后截止"
_visibility: _visibility:
@ -3144,7 +3146,7 @@ _selfXssPrevention:
description3: "详情请看这里。{link}" description3: "详情请看这里。{link}"
_followRequest: _followRequest:
recieved: "收到的请求" recieved: "收到的请求"
sent: "发送的请求" sent: "发送的请求"
_remoteLookupErrors: _remoteLookupErrors:
_federationNotAllowed: _federationNotAllowed:
title: "无法与此服务器通信" title: "无法与此服务器通信"

View File

@ -83,6 +83,8 @@ files: "檔案"
download: "下載" download: "下載"
driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此檔案的貼文也會跟著被刪除。" driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此檔案的貼文也會跟著被刪除。"
unfollowConfirm: "確定要取消追隨{name}嗎?" unfollowConfirm: "確定要取消追隨{name}嗎?"
cancelFollowRequestConfirm: "要取消向 {name} 送出的追隨申請嗎?"
rejectFollowRequestConfirm: "要拒絕來自 {name} 的追隨申請嗎?"
exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端硬碟裡。" exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端硬碟裡。"
importRequested: "已請求匯入。這可能會花一點時間。" importRequested: "已請求匯入。這可能會花一點時間。"
lists: "清單" lists: "清單"

View File

@ -1,22 +1,24 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.11.1-beta.1", "version": "2025.11.2-alpha.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/misskey-dev/misskey.git" "url": "https://github.com/misskey-dev/misskey.git"
}, },
"packageManager": "pnpm@10.22.0", "packageManager": "pnpm@10.23.0",
"workspaces": [ "workspaces": [
"packages/frontend-shared",
"packages/frontend",
"packages/frontend-embed",
"packages/icons-subsetter",
"packages/backend",
"packages/sw",
"packages/misskey-js", "packages/misskey-js",
"packages/i18n",
"packages/misskey-reversi", "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, "private": true,
"scripts": { "scripts": {
@ -58,9 +60,9 @@
"esbuild": "0.27.0", "esbuild": "0.27.0",
"execa": "9.6.0", "execa": "9.6.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"glob": "11.1.0", "glob": "13.0.0",
"ignore-walk": "8.0.0", "ignore-walk": "8.0.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.1",
"postcss": "8.5.6", "postcss": "8.5.6",
"tar": "7.5.2", "tar": "7.5.2",
"terser": "5.44.1", "terser": "5.44.1",
@ -68,18 +70,19 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"i18n": "workspace:*",
"@misskey-dev/eslint-plugin": "2.2.0", "@misskey-dev/eslint-plugin": "2.2.0",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0", "@typescript-eslint/parser": "8.47.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"cypress": "15.6.0", "cypress": "15.7.0",
"eslint": "9.39.1", "eslint": "9.39.1",
"globals": "16.5.0", "globals": "16.5.0",
"ncp": "2.0.0", "ncp": "2.0.0",
"pnpm": "10.22.0", "pnpm": "10.23.0",
"start-server-and-test": "2.1.2" "start-server-and-test": "2.1.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tensorflow/tfjs-core": "4.22.0" "@tensorflow/tfjs-core": "4.22.0"

View File

@ -40,17 +40,17 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-android-arm64": "1.3.11", "@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.15.1", "@swc/core-darwin-arm64": "1.15.3",
"@swc/core-darwin-x64": "1.15.1", "@swc/core-darwin-x64": "1.15.3",
"@swc/core-freebsd-x64": "1.3.11", "@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.15.1", "@swc/core-linux-arm-gnueabihf": "1.15.3",
"@swc/core-linux-arm64-gnu": "1.15.1", "@swc/core-linux-arm64-gnu": "1.15.3",
"@swc/core-linux-arm64-musl": "1.15.1", "@swc/core-linux-arm64-musl": "1.15.3",
"@swc/core-linux-x64-gnu": "1.15.1", "@swc/core-linux-x64-gnu": "1.15.3",
"@swc/core-linux-x64-musl": "1.15.1", "@swc/core-linux-x64-musl": "1.15.3",
"@swc/core-win32-arm64-msvc": "1.15.1", "@swc/core-win32-arm64-msvc": "1.15.3",
"@swc/core-win32-ia32-msvc": "1.15.1", "@swc/core-win32-ia32-msvc": "1.15.3",
"@swc/core-win32-x64-msvc": "1.15.1", "@swc/core-win32-x64-msvc": "1.15.3",
"@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0", "@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.9", "bufferutil": "4.0.9",
@ -70,81 +70,79 @@
"utf-8-validate": "6.0.5" "utf-8-validate": "6.0.5"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.927.0", "@aws-sdk/client-s3": "3.937.0",
"@aws-sdk/lib-storage": "3.927.0", "@aws-sdk/lib-storage": "3.937.0",
"@discordapp/twemoji": "16.0.1", "@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.3", "@fastify/accepts": "5.0.3",
"@fastify/cookie": "11.0.2", "@fastify/cookie": "11.0.2",
"@fastify/cors": "10.1.0", "@fastify/cors": "11.1.0",
"@fastify/express": "4.0.2", "@fastify/express": "4.0.2",
"@fastify/http-proxy": "10.0.2", "@fastify/http-proxy": "11.3.0",
"@fastify/multipart": "9.3.0", "@fastify/multipart": "9.3.0",
"@fastify/static": "8.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/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5", "@misskey-dev/summaly": "5.2.5",
"@napi-rs/canvas": "0.1.81", "@napi-rs/canvas": "0.1.82",
"@nestjs/common": "11.1.8", "@nestjs/common": "11.1.9",
"@nestjs/core": "11.1.8", "@nestjs/core": "11.1.9",
"@nestjs/testing": "11.1.8", "@nestjs/testing": "11.1.9",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@sentry/node": "10.23.0", "@sentry/node": "10.26.0",
"@sentry/profiling-node": "10.23.0", "@sentry/profiling-node": "10.26.0",
"@simplewebauthn/server": "12.0.0", "@simplewebauthn/server": "13.2.2",
"@sinonjs/fake-timers": "11.3.1", "@sinonjs/fake-timers": "15.0.0",
"@smithy/node-http-handler": "2.5.0", "@smithy/node-http-handler": "4.4.5",
"@swc/cli": "0.7.9", "@swc/cli": "0.7.9",
"@swc/core": "1.15.1", "@swc/core": "1.15.3",
"@twemoji/parser": "16.0.0", "@twemoji/parser": "16.0.0",
"@types/redis-info": "3.0.3", "@types/redis-info": "3.0.3",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.17.1", "ajv": "8.17.1",
"archiver": "7.0.1", "archiver": "7.0.1",
"async-mutex": "0.5.0", "async-mutex": "0.5.0",
"bcryptjs": "2.4.3", "bcryptjs": "3.0.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.3", "body-parser": "2.2.0",
"bullmq": "5.63.0", "bullmq": "5.64.1",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.2", "cbor": "10.0.11",
"chalk": "5.6.2", "chalk": "5.6.2",
"chalk-template": "1.1.2", "chalk-template": "1.1.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"color-convert": "2.0.1", "color-convert": "3.1.3",
"content-disposition": "0.5.4", "content-disposition": "1.0.1",
"date-fns": "2.30.0", "date-fns": "4.1.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fastify": "5.6.2", "fastify": "5.6.2",
"fastify-raw-body": "5.0.0", "fastify-raw-body": "5.0.0",
"feed": "4.2.2", "feed": "5.1.0",
"file-type": "21.0.0", "file-type": "21.1.1",
"fluent-ffmpeg": "2.1.3", "fluent-ffmpeg": "2.1.3",
"form-data": "4.0.4", "form-data": "4.0.5",
"got": "14.6.3", "got": "14.6.4",
"happy-dom": "20.0.10",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3", "http-link-header": "1.1.3",
"ioredis": "5.8.2", "ioredis": "5.8.2",
"ip-cidr": "4.0.2", "ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0", "ipaddr.js": "2.2.0",
"is-svg": "5.1.0", "is-svg": "6.1.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.1",
"jsdom": "26.1.0",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.3", "jsonld": "9.0.0",
"jsrsasign": "11.1.0", "jsrsasign": "11.1.0",
"juice": "11.0.3", "juice": "11.0.3",
"meilisearch": "0.54.0", "meilisearch": "0.54.0",
"mfm-js": "0.25.0", "mfm-js": "0.25.0",
"microformats-parser": "2.0.4", "microformats-parser": "2.0.4",
"mime-types": "2.1.35", "mime-types": "3.0.2",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.202508261828", "ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.6", "nanoid": "5.1.6",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-html-parser": "7.0.1",
"nodemailer": "7.0.10", "nodemailer": "7.0.10",
"nsfwjs": "4.2.0", "nsfwjs": "4.2.0",
"oauth": "0.10.2", "oauth": "0.10.2",
@ -152,9 +150,8 @@
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "9.4.1", "otpauth": "9.4.1",
"parse5": "7.3.0",
"pg": "8.16.3", "pg": "8.16.3",
"pkce-challenge": "4.1.0", "pkce-challenge": "5.0.0",
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"pug": "3.0.3", "pug": "3.0.3",
@ -163,13 +160,12 @@
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.22.3", "re2": "1.22.3",
"redis-info": "3.1.0", "redis-info": "3.1.0",
"redis-lock": "0.1.4",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rename": "1.0.4", "rename": "1.0.4",
"rss-parser": "3.13.0", "rss-parser": "3.13.0",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"sanitize-html": "2.17.0", "sanitize-html": "2.17.0",
"secure-json-parse": "3.0.2", "secure-json-parse": "4.1.0",
"semver": "7.7.3", "semver": "7.7.3",
"sharp": "0.33.5", "sharp": "0.33.5",
"slacc": "0.0.10", "slacc": "0.0.10",
@ -182,7 +178,7 @@
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typeorm": "0.3.27", "typeorm": "0.3.27",
"typescript": "5.9.3", "typescript": "5.9.3",
"ulid": "2.4.0", "ulid": "3.0.1",
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.6.7", "web-push": "3.6.7",
"ws": "8.18.3", "ws": "8.18.3",
@ -190,28 +186,25 @@
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "29.7.0", "@jest/globals": "29.7.0",
"@nestjs/platform-express": "10.4.20", "@nestjs/platform-express": "11.1.9",
"@sentry/vue": "10.23.0", "@sentry/vue": "10.26.0",
"@simplewebauthn/types": "12.0.0", "@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39", "@swc/jest": "0.2.39",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
"@types/archiver": "6.0.4", "@types/archiver": "7.0.0",
"@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.6", "@types/body-parser": "1.19.6",
"@types/color-convert": "2.0.4", "@types/color-convert": "2.0.4",
"@types/content-disposition": "0.5.9", "@types/content-disposition": "0.5.9",
"@types/fluent-ffmpeg": "2.1.28", "@types/fluent-ffmpeg": "2.1.28",
"@types/htmlescape": "1.1.3",
"@types/http-link-header": "1.0.7", "@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.15", "@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.15", "@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4", "@types/mime-types": "3.0.1",
"@types/ms": "0.7.34", "@types/ms": "2.1.0",
"@types/node": "24.10.0", "@types/node": "24.10.1",
"@types/nodemailer": "6.4.21", "@types/nodemailer": "7.0.4",
"@types/oauth": "0.9.6", "@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5", "@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
@ -224,24 +217,25 @@
"@types/sanitize-html": "2.16.0", "@types/sanitize-html": "2.16.0",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.7", "@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/supertest": "6.0.3",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6", "@types/tmp": "0.2.6",
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.46.3", "@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.46.3", "@typescript-eslint/parser": "8.47.0",
"aws-sdk-client-mock": "4.1.0", "aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3", "cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"execa": "8.0.1", "execa": "9.6.0",
"fkill": "9.0.0", "fkill": "10.0.1",
"jest": "29.7.0", "jest": "29.7.0",
"jest-mock": "29.7.0", "jest-mock": "29.7.0",
"nodemon": "3.1.10", "jest-util": "29.7.0",
"pid-port": "1.0.2", "nodemon": "3.1.11",
"pid-port": "2.0.0",
"simple-oauth2": "5.1.0", "simple-oauth2": "5.1.0",
"supertest": "7.1.4" "supertest": "7.1.4"
} }

View File

@ -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>) => void;
function redisLock(client: Redis.Redis, retryDelay: number): Lock;
export = redisLock;
}

View File

@ -10,8 +10,6 @@ import * as os from 'node:os';
import cluster from 'node:cluster'; import cluster from 'node:cluster';
import chalk from 'chalk'; import chalk from 'chalk';
import chalkTemplate from 'chalk-template'; import chalkTemplate from 'chalk-template';
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { loadConfig } from '@/config.js'; import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -74,6 +72,9 @@ export async function masterMain() {
bootLogger.succ('Misskey initialized'); bootLogger.succ('Misskey initialized');
if (config.sentryForBackend) { if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({ Sentry.init({
integrations: [ integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),

View File

@ -4,8 +4,6 @@
*/ */
import cluster from 'node:cluster'; import cluster from 'node:cluster';
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { envOption } from '@/env.js'; import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js'; import { loadConfig } from '@/config.js';
import { jobQueue, server } from './common.js'; import { jobQueue, server } from './common.js';
@ -17,6 +15,9 @@ export async function workerMain() {
const config = loadConfig(); const config = loadConfig();
if (config.sentryForBackend) { if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({ Sentry.init({
integrations: [ integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),

View File

@ -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<void>) | 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);
}
}

View File

@ -21,7 +21,6 @@ import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js'; import { AiService } from './AiService.js';
import { AnnouncementService } from './AnnouncementService.js'; import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js'; import { AntennaService } from './AntennaService.js';
import { AppLockService } from './AppLockService.js';
import { AchievementService } from './AchievementService.js'; import { AchievementService } from './AchievementService.js';
import { AvatarDecorationService } from './AvatarDecorationService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js';
import { CaptchaService } from './CaptchaService.js'; import { CaptchaService } from './CaptchaService.js';
@ -166,7 +165,6 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx
const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService };
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
@ -320,7 +318,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AiService, AiService,
AnnouncementService, AnnouncementService,
AntennaService, AntennaService,
AppLockService,
AchievementService, AchievementService,
AvatarDecorationService, AvatarDecorationService,
CaptchaService, CaptchaService,
@ -470,7 +467,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AiService, $AiService,
$AnnouncementService, $AnnouncementService,
$AntennaService, $AntennaService,
$AppLockService,
$AchievementService, $AchievementService,
$AvatarDecorationService, $AvatarDecorationService,
$CaptchaService, $CaptchaService,
@ -621,7 +617,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AiService, AiService,
AnnouncementService, AnnouncementService,
AntennaService, AntennaService,
AppLockService,
AchievementService, AchievementService,
AvatarDecorationService, AvatarDecorationService,
CaptchaService, CaptchaService,
@ -770,7 +765,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AiService, $AiService,
$AnnouncementService, $AnnouncementService,
$AntennaService, $AntennaService,
$AppLockService,
$AchievementService, $AchievementService,
$AvatarDecorationService, $AvatarDecorationService,
$CaptchaService, $CaptchaService,

View File

@ -5,9 +5,9 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import * as htmlParser from 'node-html-parser';
import type { MiInstance } from '@/models/Instance.js'; import type { MiInstance } from '@/models/Instance.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -15,7 +15,6 @@ import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { DOMWindow } from 'jsdom';
type NodeInfo = { type NodeInfo = {
openRegistrations?: unknown; openRegistrations?: unknown;
@ -59,7 +58,7 @@ export class FetchInstanceMetadataService {
return await this.redisClient.set( return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1', `fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 '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 @bindThis
private async fetchDom(instance: MiInstance): Promise<Document> { private async fetchDom(instance: MiInstance): Promise<htmlParser.HTMLElement> {
this.logger.info(`Fetching HTML of ${instance.host} ...`); this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host; const url = 'https://' + instance.host;
const html = await this.httpRequestService.getHtml(url); const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html); const doc = htmlParser.parse(html);
const doc = window.document;
return doc; return doc;
} }
@ -206,12 +204,12 @@ export class FetchInstanceMetadataService {
} }
@bindThis @bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> { private async fetchFaviconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null): Promise<string | null> {
const url = 'https://' + instance.host; const url = 'https://' + instance.host;
if (doc) { if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 // 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) { if (href) {
return (new URL(href, url)).href; return (new URL(href, url)).href;
@ -232,7 +230,7 @@ export class FetchInstanceMetadataService {
} }
@bindThis @bindThis
private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { private async fetchIconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host; const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href; 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 // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
const href = const href =
[ [
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon-precomposed'))?.attributes.href,
links.find(link => link.relList.contains('apple-touch-icon'))?.href, links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon'))?.attributes.href,
links.find(link => link.relList.contains('icon'))?.href, links.find(link => link.attributes.rel?.split(/\s+/).includes('icon'))?.attributes.href,
] ]
.find(href => href); .find(href => href);
@ -261,7 +259,7 @@ export class FetchInstanceMetadataService {
} }
@bindThis @bindThis
private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { private async getThemeColor(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
if (themeColor) { if (themeColor) {
@ -273,7 +271,7 @@ export class FetchInstanceMetadataService {
} }
@bindThis @bindThis
private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { private async getSiteName(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) { if (info && info.metadata) {
if (typeof info.metadata.nodeName === 'string') { if (typeof info.metadata.nodeName === 'string') {
return info.metadata.nodeName; return info.metadata.nodeName;
@ -298,7 +296,7 @@ export class FetchInstanceMetadataService {
} }
@bindThis @bindThis
private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> { private async getDescription(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) { if (info && info.metadata) {
if (typeof info.metadata.nodeDescription === 'string') { if (typeof info.metadata.nodeDescription === 'string') {
return info.metadata.nodeDescription; return info.metadata.nodeDescription;

View File

@ -5,26 +5,19 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5'; import * as htmlParser from 'node-html-parser';
import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js'; import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { bindThis } from '@/decorators.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'; import type * as mfm from 'mfm-js';
const treeAdapter = parse5.defaultTreeAdapter;
type Node = DefaultTreeAdapterMap['node'];
type ChildNode = DefaultTreeAdapterMap['childNode'];
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
@Injectable() @Injectable()
export class MfmService { export class MfmService {
constructor( constructor(
@ -40,68 +33,68 @@ export class MfmService {
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x))); const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
const dom = parse5.parseFragment(html); const doc = htmlParser.parse(`<div>${html}</div>`);
let text = ''; let text = '';
for (const n of dom.childNodes) { for (const n of doc.childNodes) {
analyze(n); analyze(n);
} }
return text.trim(); return text.trim();
function getText(node: Node): string { function getText(node: htmlParser.Node): string {
if (treeAdapter.isTextNode(node)) return node.value; if (node instanceof htmlParser.TextNode) return node.textContent;
if (!treeAdapter.isElementNode(node)) return ''; if (!(node instanceof htmlParser.HTMLElement)) return '';
if (node.nodeName === 'br') return '\n'; if (node.tagName === 'BR') return '\n';
if (node.childNodes) { if (node.childNodes != null) {
return node.childNodes.map(n => getText(n)).join(''); return node.childNodes.map(n => getText(n)).join('');
} }
return ''; return '';
} }
function appendChildren(childNodes: ChildNode[]): void { function analyzeChildren(childNodes: htmlParser.Node[] | null): void {
if (childNodes) { if (childNodes != null) {
for (const n of childNodes) { for (const n of childNodes) {
analyze(n); analyze(n);
} }
} }
} }
function analyze(node: Node) { function analyze(node: htmlParser.Node) {
if (treeAdapter.isTextNode(node)) { if (node instanceof htmlParser.TextNode) {
text += node.value; text += node.textContent;
return; return;
} }
// Skip comment or document type node // Skip comment or document type node
if (!treeAdapter.isElementNode(node)) { if (!(node instanceof htmlParser.HTMLElement)) {
return; return;
} }
switch (node.nodeName) { switch (node.tagName) {
case 'br': { case 'BR': {
text += '\n'; text += '\n';
break; break;
} }
case 'a': { case 'A': {
const txt = getText(node); const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel'); const rel = node.attributes.rel;
const href = node.attrs.find(x => x.name === 'href'); const href = node.attributes.href;
// ハッシュタグ // ハッシュタグ
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { if (normalizedHashtagNames && href != null && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += 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('@'); const part = txt.split('@');
if (part.length === 2 && href) { if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する //#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`; const acct = `${txt}@${(new URL(href)).hostname}`;
text += acct; text += acct;
//#endregion //#endregion
} else if (part.length === 3) { } else if (part.length === 3) {
@ -116,17 +109,17 @@ export class MfmService {
if (!href) { if (!href) {
return txt; return txt;
} }
if (!txt || txt === href.value) { // #6383: Missing text node if (!txt || txt === href) { // #6383: Missing text node
if (href.value.match(urlRegexFull)) { if (href.match(urlRegexFull)) {
return href.value; return href;
} else { } else {
return `<${href.value}>`; return `<${href}>`;
} }
} }
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { if (href.match(urlRegex) && !href.match(urlRegexFull)) {
return `[${txt}](<${href.value}>)`; // #6846 return `[${txt}](<${href}>)`; // #6846
} else { } else {
return `[${txt}](${href.value})`; return `[${txt}](${href})`;
} }
}; };
@ -135,60 +128,64 @@ export class MfmService {
break; break;
} }
case 'h1': { case 'H1': {
text += '【'; text += '【';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
text += '】\n'; text += '】\n';
break; break;
} }
case 'b': case 'B':
case 'strong': { case 'STRONG': {
text += '**'; text += '**';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
text += '**'; text += '**';
break; break;
} }
case 'small': { case 'SMALL': {
text += '<small>'; text += '<small>';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
text += '</small>'; text += '</small>';
break; break;
} }
case 's': case 'S':
case 'del': { case 'DEL': {
text += '~~'; text += '~~';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
text += '~~'; text += '~~';
break; break;
} }
case 'i': case 'I':
case 'em': { case 'EM': {
text += '<i>'; text += '<i>';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
text += '</i>'; text += '</i>';
break; break;
} }
case 'ruby': { case 'RUBY': {
let ruby: [string, string][] = []; let ruby: [string, string][] = [];
for (const child of node.childNodes) { for (const child of node.childNodes) {
if (child.nodeName === 'rp') { if ((child instanceof htmlParser.TextNode) && !/\s|\[|\]/.test(child.textContent)) {
ruby.push([child.textContent, '']);
continue; continue;
} }
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
ruby.push([child.value, '']); if (!(child instanceof htmlParser.HTMLElement)) continue;
if (child.tagName === 'RP') {
continue; continue;
} }
if (child.nodeName === 'rt' && ruby.length > 0) {
if (child.tagName === 'RT' && ruby.length > 0) {
const rt = getText(child); const rt = getText(child);
if (/\s|\[|\]/.test(rt)) { if (/\s|\[|\]/.test(rt)) {
// If any space is included in rt, it is treated as a normal text // If any space is included in rt, it is treated as a normal text
ruby = []; ruby = [];
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
break; break;
} else { } else {
ruby.at(-1)![1] = rt; 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 // If any other element is included in ruby, it is treated as a normal text
ruby = []; ruby = [];
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
break; break;
} }
for (const [base, rt] of ruby) { for (const [base, rt] of ruby) {
@ -207,26 +204,30 @@ export class MfmService {
} }
// block code (<pre><code>) // block code (<pre><code>)
case 'pre': { case 'PRE': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') { if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.HTMLElement) && node.childNodes[0].tagName === 'CODE') {
text += '\n```\n'; text += '\n```\n';
text += getText(node.childNodes[0]); text += getText(node.childNodes[0]);
text += '\n```\n'; text += '\n```\n';
} else if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.TextNode) && node.childNodes[0].textContent.startsWith('<code>') && node.childNodes[0].textContent.endsWith('</code>')) {
text += '\n```\n';
text += node.childNodes[0].textContent.slice(6, -7);
text += '\n```\n';
} else { } else {
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
} }
break; break;
} }
// inline code (<code>) // inline code (<code>)
case 'code': { case 'CODE': {
text += '`'; text += '`';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
text += '`'; text += '`';
break; break;
} }
case 'blockquote': { case 'BLOCKQUOTE': {
const t = getText(node); const t = getText(node);
if (t) { if (t) {
text += '\n> '; text += '\n> ';
@ -235,33 +236,33 @@ export class MfmService {
break; break;
} }
case 'p': case 'P':
case 'h2': case 'H2':
case 'h3': case 'H3':
case 'h4': case 'H4':
case 'h5': case 'H5':
case 'h6': { case 'H6': {
text += '\n\n'; text += '\n\n';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
break; break;
} }
// other block elements // other block elements
case 'div': case 'DIV':
case 'header': case 'HEADER':
case 'footer': case 'FOOTER':
case 'article': case 'ARTICLE':
case 'li': case 'LI':
case 'dt': case 'DT':
case 'dd': { case 'DD': {
text += '\n'; text += '\n';
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
break; break;
} }
default: // includes inline elements default: // includes inline elements
{ {
appendChildren(node.childNodes); analyzeChildren(node.childNodes);
break; break;
} }
} }
@ -269,52 +270,35 @@ export class MfmService {
} }
@bindThis @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) { if (nodes == null) {
return null; return null;
} }
const { happyDOM, window } = new Window(); function toHtml(children?: mfm.MfmNode[]): string {
if (children == null) return '';
const doc = window.document; return children.map(x => handlers[x.type](x)).join('');
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 fnDefault(node: mfm.MfmFn) { function fnDefault(node: mfm.MfmFn) {
const el = doc.createElement('i'); return `<i>${toHtml(node.children)}</i>`;
appendChildren(node.children, el);
return el;
} }
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = { const handlers = {
bold: (node) => { bold: (node) => {
const el = doc.createElement('b'); return `<b>${toHtml(node.children)}</b>`;
appendChildren(node.children, el);
return el;
}, },
small: (node) => { small: (node) => {
const el = doc.createElement('small'); return `<small>${toHtml(node.children)}</small>`;
appendChildren(node.children, el);
return el;
}, },
strike: (node) => { strike: (node) => {
const el = doc.createElement('del'); return `<del>${toHtml(node.children)}</del>`;
appendChildren(node.children, el);
return el;
}, },
italic: (node) => { italic: (node) => {
const el = doc.createElement('i'); return `<i>${toHtml(node.children)}</i>`;
appendChildren(node.children, el);
return el;
}, },
fn: (node) => { fn: (node) => {
@ -323,10 +307,7 @@ export class MfmService {
const text = node.children[0].type === 'text' ? node.children[0].props.text : ''; const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try { try {
const date = new Date(parseInt(text, 10) * 1000); const date = new Date(parseInt(text, 10) * 1000);
const el = doc.createElement('time'); return `<time datetime="${escapeHtml(date.toISOString())}">${escapeHtml(date.toISOString())}</time>`;
el.setAttribute('datetime', date.toISOString());
el.textContent = date.toISOString();
return el;
} catch (err) { } catch (err) {
return fnDefault(node); return fnDefault(node);
} }
@ -336,21 +317,9 @@ export class MfmService {
if (node.children.length === 1) { if (node.children.length === 1) {
const child = node.children[0]; const child = node.children[0];
const text = child.type === 'text' ? child.props.text : ''; const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする // ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキストルビテキスト」にフォールバックするようにする
const rpStartEl = doc.createElement('rp'); return `<ruby>${escapeHtml(text.split(' ')[0])}<rp>(</rp><rt>${escapeHtml(text.split(' ')[1])}</rt><rp>)</rp></ruby>`;
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;
} else { } else {
const rt = node.children.at(-1); const rt = node.children.at(-1);
@ -359,21 +328,9 @@ export class MfmService {
} }
const text = rt.type === 'text' ? rt.props.text : ''; const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備りゅうび」となるようにする // ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキストルビテキスト」にフォールバックするようにする
const rpStartEl = doc.createElement('rp'); return `<ruby>${toHtml(node.children.slice(0, node.children.length - 1))}<rp>(</rp><rt>${escapeHtml(text.trim())}</rt><rp>)</rp></ruby>`;
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;
} }
} }
@ -384,125 +341,98 @@ export class MfmService {
}, },
blockCode: (node) => { blockCode: (node) => {
const pre = doc.createElement('pre'); return `<pre><code>${escapeHtml(node.props.code)}</code></pre>`;
const inner = doc.createElement('code');
inner.textContent = node.props.code;
pre.appendChild(inner);
return pre;
}, },
center: (node) => { center: (node) => {
const el = doc.createElement('div'); return `<div style="text-align: center;">${toHtml(node.children)}</div>`;
appendChildren(node.children, el);
return el;
}, },
emojiCode: (node) => { emojiCode: (node) => {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); return `\u200B:${escapeHtml(node.props.name)}:\u200B`;
}, },
unicodeEmoji: (node) => { unicodeEmoji: (node) => {
return doc.createTextNode(node.props.emoji); return node.props.emoji;
}, },
hashtag: (node) => { hashtag: (node) => {
const a = doc.createElement('a'); return `<a href="${escapeHtml(`${this.config.url}/tags/${encodeURIComponent(node.props.hashtag)}`)}" rel="tag">#${escapeHtml(node.props.hashtag)}</a>`;
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
return a;
}, },
inlineCode: (node) => { inlineCode: (node) => {
const el = doc.createElement('code'); return `<code>${escapeHtml(node.props.code)}</code>`;
el.textContent = node.props.code;
return el;
}, },
mathInline: (node) => { mathInline: (node) => {
const el = doc.createElement('code'); return `<code>${escapeHtml(node.props.formula)}</code>`;
el.textContent = node.props.formula;
return el;
}, },
mathBlock: (node) => { mathBlock: (node) => {
const el = doc.createElement('code'); return `<pre><code>${escapeHtml(node.props.formula)}</code></pre>`;
el.textContent = node.props.formula;
return el;
}, },
link: (node) => { link: (node) => {
const a = doc.createElement('a'); try {
a.setAttribute('href', node.props.url); const url = new URL(node.props.url);
appendChildren(node.children, a); return `<a href="${escapeHtml(url.href)}">${toHtml(node.children)}</a>`;
return a; } catch (err) {
return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`;
}
}, },
mention: (node) => { mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props; const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase()); 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) ? (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}`); : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`;
a.className = 'u-url mention'; try {
a.textContent = acct; const url = new URL(href);
return a; return `<a href="${escapeHtml(url.href)}" class="u-url mention">${escapeHtml(acct)}</a>`;
} catch (err) {
return escapeHtml(acct);
}
}, },
quote: (node) => { quote: (node) => {
const el = doc.createElement('blockquote'); return `<blockquote>${toHtml(node.children)}</blockquote>`;
appendChildren(node.children, el);
return el;
}, },
text: (node) => { text: (node) => {
if (!node.props.text.match(/[\r\n]/)) { if (!node.props.text.match(/[\r\n]/)) {
return doc.createTextNode(node.props.text); return escapeHtml(node.props.text);
} }
const el = doc.createElement('span'); let html = '';
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) { const lines = node.props.text.split(/\r\n|\r|\n/).map(x => escapeHtml(x));
el.appendChild(x === 'br' ? doc.createElement('br') : x);
for (const x of intersperse<FIXME | 'br'>('br', lines)) {
html += x === 'br' ? '<br />' : x;
} }
return el; return html;
}, },
url: (node) => { url: (node) => {
const a = doc.createElement('a'); try {
a.setAttribute('href', node.props.url); const url = new URL(node.props.url);
a.textContent = node.props.url; return `<a href="${escapeHtml(url.href)}">${escapeHtml(node.props.url)}</a>`;
return a; } catch (err) {
return escapeHtml(node.props.url);
}
}, },
search: (node) => { search: (node) => {
const a = doc.createElement('a'); return `<a href="${escapeHtml(`https://www.google.com/search?q=${encodeURIComponent(node.props.query)}`)}">${escapeHtml(node.props.content)}</a>`;
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
return a;
}, },
plain: (node) => { plain: (node) => {
const el = doc.createElement('span'); return `<span>${toHtml(node.children)}</span>`;
appendChildren(node.children, el);
return el;
}, },
}; } satisfies { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => string } as { [K in mfm.MfmNode['type']]: (node: mfm.MfmNode) => string };
appendChildren(nodes, body); return `${toHtml(nodes)}${extraHtml ?? ''}`;
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
// Remove the unnecessary namespace
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
happyDOM.close().catch(err => {});
return serialized;
} }
} }

View File

@ -202,7 +202,7 @@ export class NotificationService implements OnApplicationShutdown {
} }
// TODO // TODO
//const locales = await import('../../../../locales/index.js'); //const locales = await import('i18n');
// TODO: locale ファイルをクライアント用とサーバー用で分けたい // TODO: locale ファイルをクライアント用とサーバー用で分けたい
@ -271,7 +271,7 @@ export class NotificationService implements OnApplicationShutdown {
let untilTime = untilId ? this.toXListId(untilId) : null; let untilTime = untilId ? this.toXListId(untilId) : null;
let notifications: MiNotification[]; let notifications: MiNotification[];
for (;;) { for (; ;) {
let notificationsRes: [id: string, fields: string[]][]; let notificationsRes: [id: string, fields: string[]][];
// sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照

View File

@ -66,7 +66,6 @@ export class WebAuthnService {
userID: isoUint8Array.fromUTF8String(userId), userID: isoUint8Array.fromUTF8String(userId),
userName: userName, userName: userName,
userDisplayName: userDisplayName, userDisplayName: userDisplayName,
attestationType: 'indirect',
excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{
id: key.id, id: key.id,
transports: key.transports ?? undefined, transports: key.transports ?? undefined,

View File

@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js';
@ -14,8 +15,8 @@ import { NotePiningService } from '@/core/NotePiningService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js';
import { NoteCreateService } from '@/core/NoteCreateService.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 { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
@ -48,8 +49,8 @@ export class ApInboxService {
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.meta) @Inject(DI.redis)
private meta: MiMeta, private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -76,7 +77,6 @@ export class ApInboxService {
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
private noteDeleteService: NoteDeleteService, private noteDeleteService: NoteDeleteService,
private appLockService: AppLockService,
private apResolverService: ApResolverService, private apResolverService: ApResolverService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private apLoggerService: ApLoggerService, private apLoggerService: ApLoggerService,
@ -311,7 +311,7 @@ export class ApInboxService {
// アナウンス先が許可されているかチェック // アナウンス先が許可されているかチェック
if (!this.utilityService.isFederationAllowedUri(uri)) return; if (!this.utilityService.isFederationAllowedUri(uri)) return;
const unlock = await this.appLockService.getApLock(uri); const unlock = await acquireApObjectLock(this.redisClient, uri);
try { try {
// 既に同じURIを持つものが登録されていないかチェック // 既に同じURIを持つものが登録されていないかチェック
@ -438,7 +438,7 @@ export class ApInboxService {
} }
} }
const unlock = await this.appLockService.getApLock(uri); const unlock = await acquireApObjectLock(this.redisClient, uri);
try { try {
const exist = await this.apNoteService.fetchNote(note); const exist = await this.apNoteService.fetchNote(note);
@ -522,7 +522,7 @@ export class ApInboxService {
private async deleteNote(actor: MiRemoteUser, uri: string): Promise<string> { private async deleteNote(actor: MiRemoteUser, uri: string): Promise<string> {
this.logger.info(`Deleting the Note: ${uri}`); this.logger.info(`Deleting the Note: ${uri}`);
const unlock = await this.appLockService.getApLock(uri); const unlock = await acquireApObjectLock(this.redisClient, uri);
try { try {
const note = await this.apDbResolverService.getNoteFromApId(uri); const note = await this.apDbResolverService.getNoteFromApId(uri);

View File

@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as mfm from 'mfm-js'; 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 type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { extractApHashtagObjects } from './models/tag.js'; import { extractApHashtagObjects } from './models/tag.js';
@ -25,17 +25,17 @@ export class ApMfmService {
} }
@bindThis @bindThis
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) { public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, extraHtml: string | null = null) {
let noMisskeyContent = false; let noMisskeyContent = false;
const srcMfm = (note.text ?? ''); const srcMfm = (note.text ?? '');
const parsed = mfm.parse(srcMfm); 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; 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 { return {
content, content,

View File

@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
import type { MiPoll } from '@/models/Poll.js'; import type { MiPoll } from '@/models/Poll.js';
import type { MiPollVote } from '@/models/PollVote.js'; import type { MiPollVote } from '@/models/PollVote.js';
import { UserKeypairService } from '@/core/UserKeypairService.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 { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js';
@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { escapeHtml } from '@/misc/escape-html.js';
import { JsonLdService } from './JsonLdService.js'; import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js'; import { CONTEXT } from './misc/contexts.js';
@ -384,7 +385,7 @@ export class ApRendererService {
inReplyTo = null; inReplyTo = null;
} }
let quote; let quote: string | undefined;
if (note.renoteId) { if (note.renoteId) {
const renote = await this.notesRepository.findOneBy({ id: 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 }); 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 `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>` // Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
// 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. // For compatibility, the span part should be kept as possible.
apAppend.push((doc, body) => { extraHtml = `<br><br><span class="quote-inline">RE: <a href="${escapeHtml(quote)}">${escapeHtml(quote)}</a></span>`;
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);
});
} }
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; 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 emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));

View File

@ -6,7 +6,7 @@
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; 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 { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
@ -215,29 +215,9 @@ export class ApRequestService {
_followAlternate === true _followAlternate === true
) { ) {
const html = await res.text(); 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 { try {
document.documentElement.innerHTML = html; const document = htmlParser.parse(html);
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) { if (alternate) {
@ -248,8 +228,6 @@ export class ApRequestService {
} }
} catch (e) { } catch (e) {
// something went wrong parsing the HTML, ignore the whole thing // something went wrong parsing the HTML, ignore the whole thing
} finally {
happyDOM.close().catch(err => {});
} }
} }
//#endregion //#endregion

View File

@ -5,14 +5,15 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js'; import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiRemoteUser } from '@/models/User.js'; import type { MiRemoteUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.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 { toArray, toSingle, unique } from '@/misc/prelude/array.js';
import type { MiEmoji } from '@/models/Emoji.js'; import type { MiEmoji } from '@/models/Emoji.js';
import { AppLockService } from '@/core/AppLockService.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import { NoteCreateService } from '@/core/NoteCreateService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
@ -48,6 +49,9 @@ export class ApNoteService {
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.pollsRepository) @Inject(DI.pollsRepository)
private pollsRepository: PollsRepository, private pollsRepository: PollsRepository,
@ -67,7 +71,6 @@ export class ApNoteService {
private apMentionService: ApMentionService, private apMentionService: ApMentionService,
private apImageService: ApImageService, private apImageService: ApImageService,
private apQuestionService: ApQuestionService, private apQuestionService: ApQuestionService,
private appLockService: AppLockService,
private pollService: PollService, private pollService: PollService,
private noteCreateService: NoteCreateService, private noteCreateService: NoteCreateService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
@ -354,7 +357,7 @@ export class ApNoteService {
throw new StatusError('blocked host', 451); throw new StatusError('blocked host', 451);
} }
const unlock = await this.appLockService.getApLock(uri); const unlock = await acquireApObjectLock(this.redisClient, uri);
try { try {
//#region このサーバーに既に登録されていたらそれを返す //#region このサーバーに既に登録されていたらそれを返す

View File

@ -5,11 +5,12 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js'; import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/active-users.js'; import { name, schema } from './entities/active-users.js';
@ -28,11 +29,13 @@ export default class ActiveUsersChart extends Chart<typeof schema> { // eslint-d
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
private chartLoggerService: ChartLoggerService, private chartLoggerService: ChartLoggerService,
private idService: IdService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,9 +5,10 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/ap-request.js'; import { name, schema } from './entities/ap-request.js';
@ -22,10 +23,12 @@ export default class ApRequestChart extends Chart<typeof schema> { // eslint-dis
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/drive.js'; import { name, schema } from './entities/drive.js';
@ -23,10 +24,12 @@ export default class DriveChart extends Chart<typeof schema> { // eslint-disable
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js'; import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/federation.js'; import { name, schema } from './entities/federation.js';
@ -26,16 +27,18 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,13 +5,14 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/instance.js'; import { name, schema } from './entities/instance.js';
@ -26,6 +27,9 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -39,10 +43,9 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,11 +5,12 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm'; import { Not, IsNull, DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/notes.js'; import { name, schema } from './entities/notes.js';
@ -24,13 +25,15 @@ export default class NotesChart extends Chart<typeof schema> { // eslint-disable
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { DriveFilesRepository } from '@/models/_.js'; import type { DriveFilesRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiDriveFile } from '@/models/DriveFile.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-drive.js'; import { name, schema } from './entities/per-user-drive.js';
@ -25,14 +26,16 @@ export default class PerUserDriveChart extends Chart<typeof schema> { // eslint-
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private appLockService: AppLockService,
private driveFileEntityService: DriveFileEntityService, private driveFileEntityService: DriveFileEntityService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm'; import { Not, IsNull, DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { FollowingsRepository } from '@/models/_.js'; import type { FollowingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-following.js'; import { name, schema } from './entities/per-user-following.js';
@ -25,14 +26,16 @@ export default class PerUserFollowingChart extends Chart<typeof schema> { // esl
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private appLockService: AppLockService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-notes.js'; import { name, schema } from './entities/per-user-notes.js';
@ -25,13 +26,15 @@ export default class PerUserNotesChart extends Chart<typeof schema> { // eslint-
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private appLockService: AppLockService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-pv.js'; import { name, schema } from './entities/per-user-pv.js';
@ -23,10 +24,12 @@ export default class PerUserPvChart extends Chart<typeof schema> { // eslint-dis
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/per-user-reactions.js'; import { name, schema } from './entities/per-user-reactions.js';
@ -25,11 +26,13 @@ export default class PerUserReactionsChart extends Chart<typeof schema> { // esl
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { name, schema } from './entities/test-grouped.js'; import { name, schema } from './entities/test-grouped.js';
import type { KVs } from '../core.js'; import type { KVs } from '../core.js';
@ -24,10 +25,12 @@ export default class TestGroupedChart extends Chart<typeof schema> { // eslint-d
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
logger: Logger, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { name, schema } from './entities/test-intersection.js'; import { name, schema } from './entities/test-intersection.js';
import type { KVs } from '../core.js'; import type { KVs } from '../core.js';
@ -22,10 +23,12 @@ export default class TestIntersectionChart extends Chart<typeof schema> { // esl
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
logger: Logger, logger: Logger,
) { ) {
super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema);
} }
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { name, schema } from './entities/test-unique.js'; import { name, schema } from './entities/test-unique.js';
import type { KVs } from '../core.js'; import type { KVs } from '../core.js';
@ -22,10 +23,12 @@ export default class TestUniqueChart extends Chart<typeof schema> { // eslint-di
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
logger: Logger, logger: Logger,
) { ) {
super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema);
} }
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,10 +5,11 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppLockService } from '@/core/AppLockService.js'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { name, schema } from './entities/test.js'; import { name, schema } from './entities/test.js';
import type { KVs } from '../core.js'; import type { KVs } from '../core.js';
@ -24,10 +25,12 @@ export default class TestChart extends Chart<typeof schema> { // eslint-disable-
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
private appLockService: AppLockService, @Inject(DI.redis)
private redisClient: Redis.Redis,
logger: Logger, logger: Logger,
) { ) {
super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema);
} }
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -5,12 +5,13 @@
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm'; import { Not, IsNull, DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { acquireChartInsertLock } from '@/misc/distributed-lock.js';
import Chart from '../core.js'; import Chart from '../core.js';
import { ChartLoggerService } from '../ChartLoggerService.js'; import { ChartLoggerService } from '../ChartLoggerService.js';
import { name, schema } from './entities/users.js'; import { name, schema } from './entities/users.js';
@ -25,14 +26,16 @@ export default class UsersChart extends Chart<typeof schema> { // eslint-disable
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private appLockService: AppLockService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private chartLoggerService: ChartLoggerService, 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<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {

View File

@ -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<void>> {
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<void>> {
return acquireDistributedLock(redis, `ap-object:${uri}`, 30 * 1000, 50, 100);
}
export function acquireChartInsertLock(
redis: Redis.Redis,
name: string,
): Promise<() => Promise<void>> {
return acquireDistributedLock(redis, `chart-insert:${name}`, 30 * 1000, 50, 500);
}

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@ -5,7 +5,6 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Bull from 'bullmq'; import * as Bull from 'bullmq';
import * as Sentry from '@sentry/node';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.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 //#region system
{ {
const processer = (job: Bull.Job) => { const processer = (job: Bull.Job) => {
@ -175,7 +181,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}; };
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job));
} else { } else {
return processer(job); return processer(job);
@ -192,7 +198,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err: Error) => { .on('failed', (job, err: Error) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(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: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -232,7 +238,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}; };
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job));
} else { } else {
return processer(job); return processer(job);
@ -249,7 +255,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(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}`, { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -264,7 +270,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver //#region deliver
{ {
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job));
} else { } else {
return this.deliverProcessorService.process(job); 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('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); 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}`, { Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -304,7 +310,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox //#region inbox
{ {
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job));
} else { } else {
return this.inboxProcessorService.process(job); 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('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
.on('failed', (job, err) => { .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) }); 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}`, { Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -344,7 +350,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region user-webhook deliver //#region user-webhook deliver
{ {
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job));
} else { } else {
return this.userWebhookDeliverProcessorService.process(job); 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('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); 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}`, { Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -384,7 +390,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region system-webhook deliver //#region system-webhook deliver
{ {
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job));
} else { } else {
return this.systemWebhookDeliverProcessorService.process(job); 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('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); 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}`, { Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -434,7 +440,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}; };
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job));
} else { } else {
return processer(job); return processer(job);
@ -456,7 +462,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(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}`, { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -479,7 +485,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}; };
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job));
} else { } else {
return processer(job); return processer(job);
@ -497,7 +503,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
.on('failed', (job, err) => { .on('failed', (job, err) => {
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(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}`, { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
level: 'error', level: 'error',
extra: { job, err }, extra: { job, err },
@ -512,7 +518,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region ended poll notification //#region ended poll notification
{ {
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { 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)); return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job));
} else { } else {
return this.endedPollNotificationProcessorService.process(job); return this.endedPollNotificationProcessorService.process(job);
@ -527,7 +533,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region post scheduled note //#region post scheduled note
{ {
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { 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)); return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
} else { } else {
return this.postScheduledNoteProcessorService.process(job); return this.postScheduledNoteProcessorService.process(job);

View File

@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as stream from 'node:stream/promises'; import * as stream from 'node:stream/promises';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js'; import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
@ -37,6 +36,7 @@ export class ApiCallService implements OnApplicationShutdown {
private logger: Logger; private logger: Logger;
private userIpHistories: Map<MiUser['id'], Set<string>>; private userIpHistories: Map<MiUser['id'], Set<string>>;
private userIpHistoriesClearIntervalId: NodeJS.Timeout; private userIpHistoriesClearIntervalId: NodeJS.Timeout;
private Sentry: typeof import('@sentry/node') | null = null;
constructor( constructor(
@Inject(DI.meta) @Inject(DI.meta)
@ -59,6 +59,12 @@ export class ApiCallService implements OnApplicationShutdown {
this.userIpHistoriesClearIntervalId = setInterval(() => { this.userIpHistoriesClearIntervalId = setInterval(() => {
this.userIpHistories.clear(); this.userIpHistories.clear();
}, 1000 * 60 * 60); }, 1000 * 60 * 60);
if (this.config.sentryForBackend) {
import('@sentry/node').then((Sentry) => {
this.Sentry = Sentry;
});
}
} }
#sendApiError(reply: FastifyReply, err: ApiError): void { #sendApiError(reply: FastifyReply, err: ApiError): void {
@ -120,8 +126,8 @@ export class ApiCallService implements OnApplicationShutdown {
}, },
}); });
if (this.config.sentryForBackend) { if (this.Sentry != null) {
Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
level: 'error', level: 'error',
user: { user: {
id: userId, id: userId,
@ -432,8 +438,8 @@ export class ApiCallService implements OnApplicationShutdown {
} }
// API invoking // API invoking
if (this.config.sentryForBackend) { if (this.Sentry != null) {
return await Sentry.startSpan({ return await this.Sentry.startSpan({
name: 'API: ' + ep.name, name: 'API: ' + ep.name,
}, () => ep.exec(data, user, token, file, request.ip, request.headers) }, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));

View File

@ -5,7 +5,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import type { ClipFavoritesRepository } from '@/models/_.js'; import type { ClipFavoritesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
@ -31,11 +30,6 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { 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: [], required: [],
} as const; } as const;
@ -46,16 +40,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.clipFavoritesRepository) @Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository, private clipFavoritesRepository: ClipFavoritesRepository,
private queryService: QueryService,
private clipEntityService: ClipEntityService, private clipEntityService: ClipEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { 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 }) .andWhere('favorite.userId = :meId', { meId: me.id })
.leftJoinAndSelect('favorite.clip', 'clip'); .leftJoinAndSelect('favorite.clip', 'clip');
const favorites = await query const favorites = await query
.limit(ps.limit)
.getMany(); .getMany();
return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); return this.clipEntityService.packMany(favorites.map(x => x.clip!), me);

View File

@ -7,7 +7,7 @@ import RE2 from 're2';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms'; 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 { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js'; import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
@ -569,16 +569,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
try { try {
const html = await this.httpRequestService.getHtml(url); const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html); const doc = htmlParser.parse(html);
const doc: Document = window.document;
const myLink = `${this.config.url}/@${user.username}`; const myLink = `${this.config.url}/@${user.username}`;
const aEls = Array.from(doc.getElementsByTagName('a')); const aEls = Array.from(doc.getElementsByTagName('a'));
const linkEls = Array.from(doc.getElementsByTagName('link')); const linkEls = Array.from(doc.getElementsByTagName('link'));
const includesMyLink = aEls.some(a => a.href === myLink); const includesMyLink = aEls.some(a => a.attributes.href === myLink);
const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.attributes.rel?.split(/\s+/).includes('me') && link.attributes.href === myLink);
if (includesMyLink || includesRelMeLinks) { if (includesMyLink || includesRelMeLinks) {
await this.userProfilesRepository.createQueryBuilder('profile').update() await this.userProfilesRepository.createQueryBuilder('profile').update()
@ -588,8 +587,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}) })
.execute(); .execute();
} }
window.close();
} catch (err) { } catch (err) {
// なにもしない // なにもしない
} }

View File

@ -135,6 +135,18 @@ export const meta = {
code: 'CANNOT_RENOTE_TO_EXTERNAL', code: 'CANNOT_RENOTE_TO_EXTERNAL',
id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', 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: { limit: {
@ -252,6 +264,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
case 'c3275f19-4558-4c59-83e1-4f684b5fab66': case 'c3275f19-4558-4c59-83e1-4f684b5fab66':
throw new ApiError(meta.errors.tooManyScheduledNotes); 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: default:
throw err; throw err;
} }

View File

@ -165,6 +165,18 @@ export const meta = {
code: 'TOO_MANY_SCHEDULED_NOTES', code: 'TOO_MANY_SCHEDULED_NOTES',
id: '02f5df79-08ae-4a33-8524-f1503c8f6212', 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: { limit: {
@ -295,6 +307,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.containsTooManyMentions); throw new ApiError(meta.errors.containsTooManyMentions);
case 'bacdf856-5c51-4159-b88a-804fa5103be5': case 'bacdf856-5c51-4159-b88a-804fa5103be5':
throw new ApiError(meta.errors.tooManyScheduledNotes); 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: default:
throw err; throw err;
} }

View File

@ -6,7 +6,7 @@
import dns from 'node:dns/promises'; import dns from 'node:dns/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom'; import * as htmlParser from 'node-html-parser';
import httpLinkHeader from 'http-link-header'; import httpLinkHeader from 'http-link-header';
import ipaddr from 'ipaddr.js'; import ipaddr from 'ipaddr.js';
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; 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 text = await res.text();
const fragment = JSDOM.fragment(text); const fragment = htmlParser.parse(`<div>${text}</div>`);
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('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 name = id;
let logo: string | null = null; let logo: string | null = null;

View File

@ -15,7 +15,6 @@ import fastifyStatic from '@fastify/static';
import fastifyView from '@fastify/view'; import fastifyView from '@fastify/view';
import fastifyProxy from '@fastify/http-proxy'; import fastifyProxy from '@fastify/http-proxy';
import vary from 'vary'; import vary from 'vary';
import htmlSafeJsonStringify from 'htmlescape';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { getNoteSummary } from '@/misc/get-note-summary.js'; import { getNoteSummary } from '@/misc/get-note-summary.js';
import { DI } from '@/di-symbols.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 frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`;
const tarball = `${_dirname}/../../../../../built/tarball/`; const tarball = `${_dirname}/../../../../../built/tarball/`;
const ESCAPE_LOOKUP = {
'&': '\\u0026',
'>': '\\u003e',
'<': '\\u003c',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
} as Record<string, string>;
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
function htmlSafeJsonStringify(obj: any): string {
return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]);
}
@Injectable() @Injectable()
export class ClientServerService { export class ClientServerService {
private logger: Logger; private logger: Logger;

View File

@ -506,10 +506,10 @@ describe('クリップ', () => {
}); });
}; };
const myFavorites = async (parameters: Misskey.entities.ClipsMyFavoritesRequest, request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => { const myFavorites = async (request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => {
return successfulApiCall({ return successfulApiCall({
endpoint: 'clips/my-favorites', endpoint: 'clips/my-favorites',
parameters, parameters: {},
user: alice, user: alice,
...request, ...request,
}); });
@ -562,9 +562,8 @@ describe('クリップ', () => {
await favorite({ clipId: clip.id }); await favorite({ clipId: clip.id });
} }
const favorited = await myFavorites({ // pagenationはない。全部一気にとれる。
limit: 30, const favorited = await myFavorites();
});
assert.strictEqual(favorited.length, clips.length); assert.strictEqual(favorited.length, clips.length);
for (const clip of favorited) { for (const clip of favorited) {
assert.strictEqual(clip.favoritedCount, 1); assert.strictEqual(clip.favoritedCount, 1);
@ -618,7 +617,7 @@ describe('クリップ', () => {
const clip = await show({ clipId: aliceClip.id }); const clip = await show({ clipId: aliceClip.id });
assert.strictEqual(clip.favoritedCount, 0); assert.strictEqual(clip.favoritedCount, 0);
assert.strictEqual(clip.isFavorited, false); assert.strictEqual(clip.isFavorited, false);
assert.deepStrictEqual(await myFavorites({}), []); assert.deepStrictEqual(await myFavorites(), []);
}); });
test.each([ test.each([
@ -652,13 +651,13 @@ describe('クリップ', () => {
test('を取得できる。', async () => { test('を取得できる。', async () => {
await favorite({ clipId: aliceClip.id }); await favorite({ clipId: aliceClip.id });
const favorited = await myFavorites({}); const favorited = await myFavorites();
assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]);
}); });
test('を取得したとき他人のお気に入りは含まない。', async () => { test('を取得したとき他人のお気に入りは含まない。', async () => {
await favorite({ clipId: aliceClip.id }); await favorite({ clipId: aliceClip.id });
const favorited = await myFavorites({}, { user: bob }); const favorited = await myFavorites({ user: bob });
assert.deepStrictEqual(favorited, []); assert.deepStrictEqual(favorited, []);
}); });
}); });

View File

@ -16,7 +16,7 @@ describe('export-clips', () => {
let bob: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse;
// XXX: Any better way to get the result? // XXX: Any better way to get the result?
async function pollFirstDriveFile() { async function pollFirstDriveFile(): Promise<any> {
while (true) { while (true) {
const files = (await api('drive/files', {}, alice)).body; const files = (await api('drive/files', {}, alice)).body;
if (!files.length) { if (!files.length) {

View File

@ -73,7 +73,7 @@ describe('Webリソース', () => {
}; };
const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { 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 () => { beforeAll(async () => {

View File

@ -19,7 +19,7 @@ import {
ResourceOwnerPassword, ResourceOwnerPassword,
} from 'simple-oauth2'; } from 'simple-oauth2';
import pkceChallenge from 'pkce-challenge'; 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 Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
import { api, port, sendEnvUpdateRequest, signup } from '../utils.js'; import { api, port, sendEnvUpdateRequest, signup } from '../utils.js';
import type * as misskey from 'misskey-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 } { function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } {
const fragment = JSDOM.fragment(html); const doc = htmlParser.parse(`<div>${html}</div>`);
return { return {
transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content, transactionId: doc.querySelector('meta[name="misskey:oauth:transaction-id"]')?.attributes.content,
clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content, clientName: doc.querySelector('meta[name="misskey:oauth:client-name"]')?.attributes.content,
clientLogo: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.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<void> { async function assertDirectError(response: Response, status: number, error: string): Promise<void> {
assert.strictEqual(response.status, status); assert.strictEqual(response.status, status);
const data = await response.json(); const data = await response.json() as any;
assert.strictEqual(data.error, error); assert.strictEqual(data.error, error);
} }
@ -704,7 +704,7 @@ describe('OAuth', () => {
const response = await fetch(new URL('.well-known/oauth-authorization-server', host)); const response = await fetch(new URL('.well-known/oauth-authorization-server', host));
assert.strictEqual(response.status, 200); 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.strictEqual(body.issuer, 'http://misskey.local');
assert.ok(body.scopes_supported.includes('write:notes')); assert.ok(body.scopes_supported.includes('write:notes'));
}); });

View File

@ -9,3 +9,4 @@ beforeAll(async () => {
await initTestDb(false); await initTestDb(false);
await sendEnvResetRequest(); await sendEnvResetRequest();
}); });

View File

@ -26,7 +26,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock'; import type { MockMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global); const moduleMocker = new ModuleMocker(global);
@ -84,7 +84,7 @@ describe('AnnouncementService', () => {
log: jest.fn(), log: jest.fn(),
}; };
} else if (typeof token === 'function') { } else if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata); const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock(); return new Mock();
} }

View File

@ -9,7 +9,6 @@ import { Test } from '@nestjs/testing';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { ApMfmService } from '@/core/activitypub/ApMfmService.js'; import { ApMfmService } from '@/core/activitypub/ApMfmService.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { MiNote } from '@/models/Note.js';
describe('ApMfmService', () => { describe('ApMfmService', () => {
let apMfmService: ApMfmService; let apMfmService: ApMfmService;
@ -31,7 +30,7 @@ describe('ApMfmService', () => {
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);
assert.equal(noMisskeyContent, true, 'noMisskeyContent'); assert.equal(noMisskeyContent, true, 'noMisskeyContent');
assert.equal(content, '<p>テキスト <a href="http://misskey.local/tags/タグ" rel="tag">#タグ</a> <a href="http://misskey.local/@mention" class="u-url mention">@mention</a> 🍊 :emoji: <a href="https://example.com">https://example.com</a></p>', 'content'); assert.equal(content, 'テキスト <a href="http://misskey.local/tags/%E3%82%BF%E3%82%B0" rel="tag">#タグ</a> <a href="http://misskey.local/@mention" class="u-url mention">@mention</a> 🍊 :emoji: <a href="https://example.com/">https://example.com</a>', 'content');
}); });
test('Provide _misskey_content for MFM', () => { test('Provide _misskey_content for MFM', () => {
@ -43,7 +42,7 @@ describe('ApMfmService', () => {
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);
assert.equal(noMisskeyContent, false, 'noMisskeyContent'); assert.equal(noMisskeyContent, false, 'noMisskeyContent');
assert.equal(content, '<p><i>foo</i></p>', 'content'); assert.equal(content, '<i>foo</i>', 'content');
}); });
}); });
}); });

View File

@ -446,7 +446,7 @@ describe('CaptchaService', () => {
if (!res.success) { if (!res.success) {
expect(res.error.code).toBe(code); expect(res.error.code).toBe(code);
} }
expect(metaService.update).not.toBeCalled(); expect(metaService.update).not.toHaveBeenCalled();
} }
describe('invalidParameters', () => { describe('invalidParameters', () => {

View File

@ -53,7 +53,7 @@ describe('DriveService', () => {
s3Mock.on(DeleteObjectCommand) s3Mock.on(DeleteObjectCommand)
.rejects(new InvalidObjectState({ $metadata: {}, message: '' })); .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 () => { test('delete a file with no valid key', async () => {

View File

@ -17,7 +17,7 @@ import { FileInfo, FileInfoService } from '@/core/FileInfoService.js';
import { AiService } from '@/core/AiService.js'; import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type { TestingModule } from '@nestjs/testing'; 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 _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -54,7 +54,7 @@ describe('FileInfoService', () => {
// return { }; // return { };
//} //}
if (typeof token === 'function') { if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata); const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock(); return new Mock();
} }

View File

@ -24,25 +24,25 @@ describe('MfmService', () => {
describe('toHtml', () => { describe('toHtml', () => {
test('br', () => { test('br', () => {
const input = 'foo\nbar\nbaz'; const input = 'foo\nbar\nbaz';
const output = '<p><span>foo<br />bar<br />baz</span></p>'; const output = 'foo<br />bar<br />baz';
assert.equal(mfmService.toHtml(mfm.parse(input)), output); assert.equal(mfmService.toHtml(mfm.parse(input)), output);
}); });
test('br alt', () => { test('br alt', () => {
const input = 'foo\r\nbar\rbaz'; const input = 'foo\r\nbar\rbaz';
const output = '<p><span>foo<br />bar<br />baz</span></p>'; const output = 'foo<br />bar<br />baz';
assert.equal(mfmService.toHtml(mfm.parse(input)), output); assert.equal(mfmService.toHtml(mfm.parse(input)), output);
}); });
test('Do not generate unnecessary span', () => { test('Do not generate unnecessary span', () => {
const input = 'foo $[tada bar]'; const input = 'foo $[tada bar]';
const output = '<p>foo <i>bar</i></p>'; const output = 'foo <i>bar</i>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output); assert.equal(mfmService.toHtml(mfm.parse(input)), output);
}); });
test('escape', () => { test('escape', () => {
const input = '```\n<p>Hello, world!</p>\n```'; const input = '```\n<p>Hello, world!</p>\n```';
const output = '<p><pre><code>&lt;p&gt;Hello, world!&lt;/p&gt;</code></pre></p>'; const output = '<pre><code>&lt;p&gt;Hello, world!&lt;/p&gt;</code></pre>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output); assert.equal(mfmService.toHtml(mfm.parse(input)), output);
}); });
}); });
@ -118,7 +118,7 @@ describe('MfmService', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <ruby>Misskey<rp>(</rp><rt>ミス キー</rt><rp>)</rp> b</ruby> c</p>'), 'a Misskey(ミス キー) b c'); assert.deepStrictEqual(mfmService.fromHtml('<p>a <ruby>Misskey<rp>(</rp><rt>ミス キー</rt><rp>)</rp> b</ruby> c</p>'), 'a Misskey(ミス キー) b c');
assert.deepStrictEqual( assert.deepStrictEqual(
mfmService.fromHtml('<p>a <ruby>Misskey<rp>(</rp><rt>ミスキー</rt><rp>)</rp>Misskey<rp>(</rp><rt>ミス キー</rt><rp>)</rp>Misskey<rp>(</rp><rt>ミスキー</rt><rp>)</rp></ruby> b</p>'), mfmService.fromHtml('<p>a <ruby>Misskey<rp>(</rp><rt>ミスキー</rt><rp>)</rp>Misskey<rp>(</rp><rt>ミス キー</rt><rp>)</rp>Misskey<rp>(</rp><rt>ミスキー</rt><rp>)</rp></ruby> b</p>'),
'a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b' 'a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b',
); );
}); });

View File

@ -9,7 +9,7 @@ import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { ModuleMocker } from 'jest-mock'; import { ModuleMocker } from 'jest-mock';
import type { TestingModule } from '@nestjs/testing'; 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 { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -45,7 +45,7 @@ describe('RelayService', () => {
return { deliver: jest.fn() }; return { deliver: jest.fn() };
} }
if (typeof token === 'function') { if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata); const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock(); return new Mock();
} }

View File

@ -11,7 +11,7 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers'; import * as lolex from '@sinonjs/fake-timers';
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock'; import type { MockMetadata } from 'jest-mock';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { import {
@ -104,6 +104,8 @@ describe('RoleService', () => {
beforeEach(async () => { beforeEach(async () => {
clock = lolex.install({ 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(), now: new Date(),
shouldClearNativeTimers: true, shouldClearNativeTimers: true,
}); });
@ -135,7 +137,7 @@ describe('RoleService', () => {
return { fetch: jest.fn() }; return { fetch: jest.fn() };
} }
if (typeof token === 'function') { if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata); const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock(); return new Mock();
} }

View File

@ -72,7 +72,7 @@ describe('S3Service', () => {
Bucket: 'fake', Bucket: 'fake',
Key: 'fake', Key: 'fake',
Body: 'x', Body: 'x',
})).rejects.toThrowError(Error); })).rejects.toThrow(Error);
}); });
test('upload a large file error', async () => { test('upload a large file error', async () => {
@ -82,7 +82,7 @@ describe('S3Service', () => {
Bucket: 'fake', Bucket: 'fake',
Key: 'fake', Key: 'fake',
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ
})).rejects.toThrowError(Error); })).rejects.toThrow(Error);
}); });
}); });
}); });

View File

@ -9,7 +9,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import { AuthenticationResponseJSON } from '@simplewebauthn/types';
import { HttpHeader } from 'fastify/types/utils.js'; 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 { MiUser } from '@/models/User.js';
import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -95,7 +95,7 @@ describe('SigninWithPasskeyApiService', () => {
], ],
}).useMocker((token) => { }).useMocker((token) => {
if (typeof token === 'function') { if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata); const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock(); return new Mock();
} }

View File

@ -9,6 +9,7 @@ import * as assert from 'assert';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import * as lolex from '@sinonjs/fake-timers'; import * as lolex from '@sinonjs/fake-timers';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as Redis from 'ioredis';
import TestChart from '@/core/chart/charts/test.js'; import TestChart from '@/core/chart/charts/test.js';
import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; import TestGroupedChart from '@/core/chart/charts/test-grouped.js';
import TestUniqueChart from '@/core/chart/charts/test-unique.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 TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js';
import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js';
import { loadConfig } from '@/config.js'; import { loadConfig } from '@/config.js';
import type { AppLockService } from '@/core/AppLockService.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
describe('Chart', () => { describe('Chart', () => {
const config = loadConfig(); const config = loadConfig();
const appLockService = {
getChartInsertLock: () => () => Promise.resolve(() => {}),
} as unknown as jest.Mocked<AppLockService>;
let db: DataSource | undefined; let db: DataSource | undefined;
let redisClient = {
set: () => Promise.resolve('OK'),
get: () => Promise.resolve(null),
} as unknown as jest.Mocked<Redis.Redis>;
let testChart: TestChart; let testChart: TestChart;
let testGroupedChart: TestGroupedChart; let testGroupedChart: TestGroupedChart;
@ -64,12 +65,14 @@ describe('Chart', () => {
await db.initialize(); await db.initialize();
const logger = new Logger('chart'); // TODO: モックにする const logger = new Logger('chart'); // TODO: モックにする
testChart = new TestChart(db, appLockService, logger); testChart = new TestChart(db, redisClient, logger);
testGroupedChart = new TestGroupedChart(db, appLockService, logger); testGroupedChart = new TestGroupedChart(db, redisClient, logger);
testUniqueChart = new TestUniqueChart(db, appLockService, logger); testUniqueChart = new TestUniqueChart(db, redisClient, logger);
testIntersectionChart = new TestIntersectionChart(db, appLockService, logger); testIntersectionChart = new TestIntersectionChart(db, redisClient, logger);
clock = lolex.install({ 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)), now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)),
shouldClearNativeTimers: true, shouldClearNativeTimers: true,
}); });

View File

@ -141,6 +141,8 @@ describe('CheckModeratorsActivityProcessorService', () => {
beforeEach(async () => { beforeEach(async () => {
clock = lolex.install({ 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), now: new Date(baseDate),
shouldClearNativeTimers: true, shouldClearNativeTimers: true,
}); });

View File

@ -10,8 +10,8 @@ import { randomUUID } from 'node:crypto';
import { inspect } from 'node:util'; import { inspect } from 'node:util';
import WebSocket, { ClientOptions } from 'ws'; import WebSocket, { ClientOptions } from 'ws';
import fetch, { File, RequestInit, type Headers } from 'node-fetch'; import fetch, { File, RequestInit, type Headers } from 'node-fetch';
import * as htmlParser from 'node-html-parser';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom';
import { type Response } from 'node-fetch'; import { type Response } from 'node-fetch';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { entities } from '../src/postgres.js'; import { entities } from '../src/postgres.js';
@ -468,7 +468,7 @@ export function makeStreamCatcher<T>(
export type SimpleGetResponse = { export type SimpleGetResponse = {
status: number, status: number,
body: any | JSDOM | null, body: any | null,
type: string | null, type: string | null,
location: string | null location: string | null
}; };
@ -499,7 +499,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
const body = const body =
jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : 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); await bodyExtractor(res);
return { return {

View File

@ -10,7 +10,7 @@ import { collectModifications } from './locale-inliner/collect-modifications.js'
import { applyWithLocale } from './locale-inliner/apply-with-locale.js'; import { applyWithLocale } from './locale-inliner/apply-with-locale.js';
import { blankLogger } from './logger.js'; import { blankLogger } from './logger.js';
import type { Logger } 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'; import type { Manifest as ViteManifest } from 'vite';
export class LocaleInliner { export class LocaleInliner {

View File

@ -5,7 +5,7 @@
import MagicString from 'magic-string'; import MagicString from 'magic-string';
import { assertNever } from '../utils.js'; 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 { TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js'; import type { Logger } from '../logger.js';

View File

@ -18,8 +18,9 @@
"typescript": "5.9.3" "typescript": "5.9.3"
}, },
"dependencies": { "dependencies": {
"i18n": "workspace:*",
"estree-walker": "3.0.3", "estree-walker": "3.0.3",
"magic-string": "0.30.21", "magic-string": "0.30.21",
"vite": "7.2.2" "vite": "7.2.4"
} }
} }

View File

@ -2,7 +2,7 @@ import * as fs from 'fs/promises';
import url from 'node:url'; import url from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { execa } from 'execa'; import { execa } from 'execa';
import locales from '../../locales/index.js'; import locales from 'i18n';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js' import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger'; import { createLogger } from '../frontend-builder/logger';

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "16.0.1", "@discordapp/twemoji": "16.0.1",
"i18n": "workspace:*",
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.3", "@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0", "@rollup/pluginutils": "5.3.0",
@ -27,14 +28,14 @@
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.53.3", "rollup": "4.53.3",
"sass": "1.94.1", "sass": "1.94.2",
"shiki": "3.15.0", "shiki": "3.15.0",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"uuid": "13.0.0", "uuid": "13.0.0",
"vite": "7.2.2", "vite": "7.2.4",
"vue": "3.5.24" "vue": "3.5.24"
}, },
"devDependencies": { "devDependencies": {
@ -49,12 +50,12 @@
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "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", "@vue/runtime-core": "3.5.24",
"acorn": "8.15.0", "acorn": "8.15.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.5.1", "eslint-plugin-vue": "10.6.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"happy-dom": "20.0.10", "happy-dom": "20.0.10",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
@ -62,11 +63,11 @@
"msw": "2.12.2", "msw": "2.12.2",
"nodemon": "3.1.11", "nodemon": "3.1.11",
"prettier": "3.6.2", "prettier": "3.6.2",
"start-server-and-test": "2.1.2", "start-server-and-test": "2.1.3",
"tsx": "4.20.6", "tsx": "4.20.6",
"vite-plugin-turbosnap": "1.0.3", "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-eslint-parser": "10.2.0",
"vue-tsc": "3.1.4" "vue-tsc": "3.1.5"
} }
} }

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts" generic="T extends string | ParameterizedString"> <script setup lang="ts" generic="T extends string | ParameterizedString">
import { computed, h } from 'vue'; import { computed, h } from 'vue';
import type { ParameterizedString } from '../../../../locales/index.js'; import type { ParameterizedString } from 'i18n';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
src: T; src: T;
@ -25,7 +25,7 @@ const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: (
const parsed = computed(() => { const parsed = computed(() => {
let str = props.src as string; let str = props.src as string;
const value: (string | { arg: string; })[] = []; const value: (string | { arg: string; })[] = [];
for (;;) { for (; ;) {
const nextBracketOpen = str.indexOf('{'); const nextBracketOpen = str.indexOf('{');
const nextBracketClose = str.indexOf('}'); const nextBracketClose = str.indexOf('}');

View File

@ -6,7 +6,7 @@
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js'; import { I18n } from '@@/js/i18n.js';
import { locale } from '@@/js/locale.js'; import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js'; import type { Locale } from 'i18n';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));

View File

@ -1,10 +1,10 @@
import path from 'path'; import path from 'path';
import pluginVue from '@vitejs/plugin-vue'; import pluginVue from '@vitejs/plugin-vue';
import { type UserConfig, defineConfig } from 'vite'; import { defineConfig, type UserConfig } from 'vite';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { promises as fsp } from 'fs'; import { promises as fsp } from 'fs';
import locales from '../../locales/index.js'; import locales from 'i18n';
import meta from '../../package.json'; import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' }; import packageInfo from './package.json' with { type: 'json' };
import pluginJson5 from './vite.json5.js'; import pluginJson5 from './vite.json5.js';

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ILocale, ParameterizedString } from '../../../locales/index.js'; import type { ILocale, ParameterizedString } from 'i18n';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type TODO = any; type TODO = any;

View File

@ -4,7 +4,7 @@
*/ */
import { lang, version } from '@@/js/config.js'; import { lang, version } from '@@/js/config.js';
import type { Locale } from '../../../locales/index.js'; import type { Locale } from 'i18n';
// ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える // ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える
export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null); export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null);

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Locale } from '../../../locales/index.js'; import type { Locale } from 'i18n';
type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] }; type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] };

View File

@ -25,7 +25,7 @@
"@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0", "@typescript-eslint/parser": "8.47.0",
"esbuild": "0.27.0", "esbuild": "0.27.0",
"eslint-plugin-vue": "10.5.1", "eslint-plugin-vue": "10.6.0",
"nodemon": "3.1.11", "nodemon": "3.1.11",
"typescript": "5.9.3", "typescript": "5.9.3",
"vue-eslint-parser": "10.2.0" "vue-eslint-parser": "10.2.0"
@ -34,6 +34,7 @@
"js-built" "js-built"
], ],
"dependencies": { "dependencies": {
"i18n": "workspace:*",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"vue": "3.5.24" "vue": "3.5.24"
} }

View File

@ -4,7 +4,7 @@
*/ */
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import locales from '../../../locales/index.js'; import locales from 'i18n';
await writeFile( await writeFile(
new URL('locale.ts', import.meta.url), new URL('locale.ts', import.meta.url),

View File

@ -2,7 +2,7 @@ import * as fs from 'fs/promises';
import url from 'node:url'; import url from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { execa } from 'execa'; import { execa } from 'execa';
import locales from '../../locales/index.js'; import locales from 'i18n';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js' import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger'; import { createLogger } from '../frontend-builder/logger';

View File

@ -4,7 +4,7 @@
*/ */
import path from 'node:path' import path from 'node:path'
import locales from '../../../locales/index.js'; import locales from 'i18n';
const localesDir = path.resolve(__dirname, '../../../locales') const localesDir = path.resolve(__dirname, '../../../locales')

View File

@ -20,12 +20,13 @@
"@discordapp/twemoji": "16.0.1", "@discordapp/twemoji": "16.0.1",
"@github/webauthn-json": "2.1.1", "@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"i18n": "workspace:*",
"@misskey-dev/browser-image-resizer": "2024.1.0", "@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.3", "@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0", "@rollup/pluginutils": "5.3.0",
"@sentry/vue": "10.26.0", "@sentry/vue": "10.26.0",
"@syuilo/aiscript": "1.1.2", "@syuilo/aiscript": "1.2.0",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0", "@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.2", "@vitejs/plugin-vue": "6.0.2",
@ -58,7 +59,7 @@
"json5": "2.2.3", "json5": "2.2.3",
"magic-string": "0.30.21", "magic-string": "0.30.21",
"matter-js": "0.20.0", "matter-js": "0.20.0",
"mediabunny": "1.25.0", "mediabunny": "1.25.1",
"mfm-js": "0.25.0", "mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*", "misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
@ -69,7 +70,7 @@
"qr-scanner": "1.4.2", "qr-scanner": "1.4.2",
"rollup": "4.53.3", "rollup": "4.53.3",
"sanitize-html": "2.17.0", "sanitize-html": "2.17.0",
"sass": "1.94.1", "sass": "1.94.2",
"shiki": "3.15.0", "shiki": "3.15.0",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
@ -80,7 +81,7 @@
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"v-code-diff": "1.13.1", "v-code-diff": "1.13.1",
"vite": "7.2.2", "vite": "7.2.4",
"vue": "3.5.24", "vue": "3.5.24",
"vuedraggable": "next", "vuedraggable": "next",
"wanakana": "5.3.1" "wanakana": "5.3.1"
@ -89,7 +90,7 @@
"@misskey-dev/summaly": "5.2.5", "@misskey-dev/summaly": "5.2.5",
"@storybook/addon-essentials": "8.6.14", "@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14", "@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "9.1.16", "@storybook/addon-links": "10.0.8",
"@storybook/addon-mdx-gfm": "8.6.14", "@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/addon-storysource": "8.6.14", "@storybook/addon-storysource": "8.6.14",
"@storybook/blocks": "8.6.14", "@storybook/blocks": "8.6.14",
@ -97,13 +98,13 @@
"@storybook/core-events": "8.6.14", "@storybook/core-events": "8.6.14",
"@storybook/manager-api": "8.6.14", "@storybook/manager-api": "8.6.14",
"@storybook/preview-api": "8.6.14", "@storybook/preview-api": "8.6.14",
"@storybook/react": "9.1.16", "@storybook/react": "10.0.8",
"@storybook/react-vite": "9.1.16", "@storybook/react-vite": "10.0.8",
"@storybook/test": "8.6.14", "@storybook/test": "8.6.14",
"@storybook/theming": "8.6.14", "@storybook/theming": "8.6.14",
"@storybook/types": "8.6.14", "@storybook/types": "8.6.14",
"@storybook/vue3": "9.1.16", "@storybook/vue3": "10.0.8",
"@storybook/vue3-vite": "9.1.16", "@storybook/vue3-vite": "10.0.8",
"@tabler/icons-webfont": "3.35.0", "@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0", "@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0", "@types/canvas-confetti": "1.9.0",
@ -119,14 +120,14 @@
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0", "@typescript-eslint/parser": "8.47.0",
"@vitest/coverage-v8": "3.2.4", "@vitest/coverage-v8": "4.0.13",
"@vue/compiler-core": "3.5.24", "@vue/compiler-core": "3.5.24",
"@vue/runtime-core": "3.5.24", "@vue/runtime-core": "3.5.24",
"acorn": "8.15.0", "acorn": "8.15.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"cypress": "15.6.0", "cypress": "15.7.0",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.5.1", "eslint-plugin-vue": "10.6.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"happy-dom": "20.0.10", "happy-dom": "20.0.10",
"intersection-observer": "0.12.2", "intersection-observer": "0.12.2",
@ -139,16 +140,16 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"start-server-and-test": "2.1.2", "start-server-and-test": "2.1.3",
"storybook": "9.1.16", "storybook": "10.0.8",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.20.6", "tsx": "4.20.6",
"vite-plugin-glsl": "1.5.4", "vite-plugin-glsl": "1.5.4",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "3.2.4", "vitest": "4.0.13",
"vitest-fetch-mock": "0.4.5", "vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "3.1.4", "vue-component-type-helpers": "3.1.5",
"vue-eslint-parser": "10.2.0", "vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.4" "vue-tsc": "3.1.5"
} }
} }

View File

@ -40,29 +40,77 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value), CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
LOCALE: values.STR(lang), LOCALE: values.STR(lang),
SERVER_URL: values.STR(url), SERVER_URL: values.STR(url),
'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { 'Mk:dialog': values.FN_NATIVE(async ([_title, _text, _type]) => {
utils.assertString(title); let title: string | undefined = undefined;
utils.assertString(text); let text: string | undefined = undefined;
if (type != null) { let type: typeof DIALOG_TYPES[number] = 'info';
assertStringAndIsIn(type, DIALOG_TYPES);
if (_title != null) {
if (utils.isString(_title)) {
title = _title.value;
} else {
utils.assertNull(_title);
} }
}
if (_text != null) {
if (utils.isString(_text)) {
text = _text.value;
} else {
utils.assertNull(_text);
}
}
if (_type != null) {
if (utils.isString(_type)) {
assertStringAndIsIn(_type, DIALOG_TYPES);
type = _type.value;
} else {
utils.assertNull(_type);
}
}
await os.alert({ await os.alert({
type: type ? type.value : 'info', type,
title: title.value, title,
text: text.value, text,
}); });
return values.NULL; return values.NULL;
}), }),
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { 'Mk:confirm': values.FN_NATIVE(async ([_title, _text, _type]) => {
utils.assertString(title); let title: string | undefined = undefined;
utils.assertString(text); let text: string | undefined = undefined;
if (type != null) { let type: typeof DIALOG_TYPES[number] = 'question';
assertStringAndIsIn(type, DIALOG_TYPES);
if (_title != null) {
if (utils.isString(_title)) {
title = _title.value;
} else {
utils.assertNull(_title);
} }
}
if (_text != null) {
if (utils.isString(_text)) {
text = _text.value;
} else {
utils.assertNull(_text);
}
}
if (_type != null) {
if (utils.isString(_type)) {
assertStringAndIsIn(_type, DIALOG_TYPES);
type = _type.value;
} else {
utils.assertNull(_type);
}
}
const confirm = await os.confirm({ const confirm = await os.confirm({
type: type ? type.value : 'question', type,
title: title.value, title,
text: text.value, text,
}); });
return confirm.canceled ? values.FALSE : values.TRUE; return confirm.canceled ? values.FALSE : values.TRUE;
}), }),
@ -76,15 +124,23 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
if (ep.value.includes('://') || ep.value.includes('..')) { if (ep.value.includes('://') || ep.value.includes('..')) {
throw new errors.AiScriptRuntimeError('invalid endpoint'); throw new errors.AiScriptRuntimeError('invalid endpoint');
} }
if (token) {
let actualToken: string | null = null;
if (token != null && !utils.isNull(token)) {
utils.assertString(token); utils.assertString(token);
// バグがあればundefinedもあり得るため念のため // バグがあればundefinedもあり得るため念のため
if (typeof token.value !== 'string') throw new Error('invalid token'); if (typeof token.value !== 'string') throw new errors.AiScriptRuntimeError('invalid token');
actualToken = token.value;
} }
const actualToken: string | null = token?.value ?? opts.token ?? null;
if (actualToken == null) {
actualToken = opts.token ?? null;
}
if (param == null) { if (param == null) {
throw new errors.AiScriptRuntimeError('expected param'); throw new errors.AiScriptRuntimeError('expected param');
} }
utils.assertObject(param); utils.assertObject(param);
return misskeyApi(ep.value as keyof Misskey.Endpoints, utils.valToJs(param) as object, actualToken).then(res => { return misskeyApi(ep.value as keyof Misskey.Endpoints, utils.valToJs(param) as object, actualToken).then(res => {
return utils.jsToVal(res); return utils.jsToVal(res);

View File

@ -178,16 +178,13 @@ export async function common(createVue: () => Promise<App<Element>>) {
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light'; window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
if (!isSafeMode) { if (!isSafeMode) {
const darkTheme = prefer.model('darkTheme'); watch(prefer.r.darkTheme, (theme) => {
const lightTheme = prefer.model('lightTheme');
watch(darkTheme, (theme) => {
if (store.s.darkMode) { if (store.s.darkMode) {
applyTheme(theme ?? defaultDarkTheme); applyTheme(theme ?? defaultDarkTheme);
} }
}); });
watch(lightTheme, (theme) => { watch(prefer.r.lightTheme, (theme) => {
if (!store.s.darkMode) { if (!store.s.darkMode) {
applyTheme(theme ?? defaultLightTheme); applyTheme(theme ?? defaultLightTheme);
} }

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts" generic="T extends string | ParameterizedString"> <script setup lang="ts" generic="T extends string | ParameterizedString">
import { computed, h } from 'vue'; import { computed, h } from 'vue';
import type { ParameterizedString } from '../../../../../locales/index.js'; import type { ParameterizedString } from 'i18n';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
src: T; src: T;
@ -25,7 +25,7 @@ const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: (
const parsed = computed(() => { const parsed = computed(() => {
let str = props.src as string; let str = props.src as string;
const value: (string | { arg: string; })[] = []; const value: (string | { arg: string; })[] = [];
for (;;) { for (; ;) {
const nextBracketOpen = str.indexOf('{'); const nextBracketOpen = str.indexOf('{');
const nextBracketClose = str.indexOf('}'); const nextBracketClose = str.indexOf('}');

View File

@ -6,7 +6,7 @@
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js'; import { I18n } from '@@/js/i18n.js';
import { locale } from '@@/js/locale.js'; import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js'; import type { Locale } from 'i18n';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));

View File

@ -43,6 +43,8 @@ const paginator = markRaw(new Paginator('clips/list', {
})); }));
const favoritesPaginator = markRaw(new Paginator('clips/my-favorites', { const favoritesPaginator = markRaw(new Paginator('clips/my-favorites', {
//
noPaging: true,
})); }));
async function create() { async function create() {

View File

@ -8,7 +8,6 @@ import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
import { errors, Interpreter, Parser, values } from '@syuilo/aiscript'; import { errors, Interpreter, Parser, values } from '@syuilo/aiscript';
import { import {
afterAll, afterAll,
afterEach,
beforeAll, beforeAll,
beforeEach, beforeEach,
describe, describe,
@ -80,8 +79,9 @@ describe('AiScript common API', () => {
}); });
describe('readline', () => { describe('readline', () => {
afterEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks();
}); });
test.sequential('ok', async () => { test.sequential('ok', async () => {
@ -176,8 +176,9 @@ describe('AiScript common API', () => {
}); });
describe('dialog', () => { describe('dialog', () => {
afterEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks();
}); });
test.sequential('ok', async () => { test.sequential('ok', async () => {
@ -215,8 +216,9 @@ describe('AiScript common API', () => {
}); });
describe('confirm', () => { describe('confirm', () => {
afterEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks();
}); });
test.sequential('ok', async () => { test.sequential('ok', async () => {
@ -272,8 +274,9 @@ describe('AiScript common API', () => {
}); });
describe('api', () => { describe('api', () => {
afterEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks();
}); });
test.sequential('successful', async () => { test.sequential('successful', async () => {
@ -347,7 +350,7 @@ describe('AiScript common API', () => {
miLocalStorage.removeItem('aiscript:widget:key'); miLocalStorage.removeItem('aiscript:widget:key');
}); });
afterEach(() => { beforeEach(() => {
miLocalStorage.removeItem('aiscript:widget:key'); miLocalStorage.removeItem('aiscript:widget:key');
}); });

View File

@ -5,7 +5,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { I18n } from '../../frontend-shared/js/i18n.js'; // @@で参照できなかったので import { I18n } from '../../frontend-shared/js/i18n.js'; // @@で参照できなかったので
import type { ParameterizedString } from '../../../locales/index.js'; import type { ParameterizedString } from 'i18n';
// TODO: このテストはfrontend-sharedに移動する // TODO: このテストはfrontend-sharedに移動する

View File

@ -7,13 +7,13 @@ import { vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock'; import createFetchMock from 'vitest-fetch-mock';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { ref } from 'vue'; import { ref } from 'vue';
// Set i18n
import locales from 'i18n';
import { updateI18n } from '@/i18n.js';
const fetchMocker = createFetchMock(vi); const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks(); fetchMocker.enableMocks();
// Set i18n
import locales from '../../../locales/index.js';
import { updateI18n } from '@/i18n.js';
updateI18n(locales['en-US']); updateI18n(locales['en-US']);
// XXX: misskey-js panics if WebSocket is not defined // XXX: misskey-js panics if WebSocket is not defined

View File

@ -2,18 +2,18 @@ import path from 'path';
import pluginReplace from '@rollup/plugin-replace'; import pluginReplace from '@rollup/plugin-replace';
import pluginVue from '@vitejs/plugin-vue'; import pluginVue from '@vitejs/plugin-vue';
import pluginGlsl from 'vite-plugin-glsl'; import pluginGlsl from 'vite-plugin-glsl';
import { defineConfig } from 'vite';
import type { UserConfig } from 'vite'; import type { UserConfig } from 'vite';
import { defineConfig } from 'vite';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { promises as fsp } from 'fs'; import { promises as fsp } from 'fs';
import locales from '../../locales/index.js'; import locales from 'i18n';
import meta from '../../package.json'; import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' }; import packageInfo from './package.json' with { type: 'json' };
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js'; import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js';
import pluginJson5 from './vite.json5.js'; import pluginJson5 from './vite.json5.js';
import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js';
import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js'; import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js';
import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js';
import pluginWatchLocales from './lib/vite-plugin-watch-locales.js'; import pluginWatchLocales from './lib/vite-plugin-watch-locales.js';
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n.js'; import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n.js';

5
packages/i18n/README.md Normal file
View File

@ -0,0 +1,5 @@
# Misskey i18n
Misskey の言語ファイル本体 (ja-JP.yml など) はリポジトリ直下の `/locales` に置かれており、そこから Crowdin 連携やビルド資産が生成されます。
このパッケージは Misskey モノレポ内で、これらの言語ファイルを共通で扱うためのヘルパー群や型情報をまとめる位置づけです。バックエンド / フロントエンド / Service Worker など各パッケージが同じ翻訳データと型定義を利用できるようにすることを目的としており、npm での外部配布は想定していません。

163
packages/i18n/build.ts Normal file
View File

@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { watch as chokidarWatch } from 'chokidar';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { execa } from 'execa';
import { globSync } from 'glob';
import { generateLocaleInterface } from './scripts/generateLocaleInterface.js';
import type { BuildOptions, BuildResult, Plugin, PluginBuild } from 'esbuild';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const _rootPackageDir = resolve(_dirname, '../../');
const _rootPackage = JSON.parse(fs.readFileSync(resolve(_rootPackageDir, 'package.json'), 'utf-8'));
const _frontendLocalesDir = resolve(_dirname, '../../built/_frontend_dist_/locales');
const _localesDir = resolve(_rootPackageDir, 'locales');
const entryPoints = globSync('./src/**/**.{ts,tsx}');
const options: BuildOptions = {
entryPoints,
minify: process.env.NODE_ENV === 'production',
sourceRoot: 'src',
outdir: './built',
target: 'es2022',
platform: 'node',
format: 'esm',
sourcemap: 'linked',
};
// コマンドライン引数を取得
const args = process.argv.slice(2).map(arg => arg.toLowerCase());
// built配下をすべて削除する
if (!args.includes('--no-clean')) {
fs.rmSync('./built', { recursive: true, force: true });
}
if (args.includes('--watch')) {
await watchSrc();
} else {
await buildSrc();
}
function copyLocales(): void {
const srcDir = _localesDir;
const destDir = resolve(_dirname, 'built/locales');
fs.mkdirSync(destDir, { recursive: true });
const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.yml'));
for (const file of files) {
fs.copyFileSync(resolve(srcDir, file), resolve(destDir, file));
}
console.log(`[${_package.name}] locales copied (${files.length} files).`);
}
/**
* locale JSON
* Service Worker HTTP
*/
async function writeFrontendLocalesJson(): Promise<void> {
// 動的 import でビルド済みモジュールから読み込み(循環参照回避)
const { writeFrontendLocalesJson: write } = await import('./built/index.js');
await write(_frontendLocalesDir, _rootPackage.version);
console.log(`[${_package.name}] frontend locales JSON written to ${_frontendLocalesDir}`);
}
async function buildSrc(): Promise<void> {
console.log(`[${_package.name}] start building...`);
await generateLocaleInterface(_localesDir);
await build(options)
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
process.stderr.write(err.stderr);
process.exit(1);
});
copyLocales();
await writeFrontendLocalesJson();
if (process.env.NODE_ENV === 'production') {
console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`);
} else {
await buildDts();
}
console.log(`[${_package.name}] finish building.`);
}
function buildDts(): Promise<unknown> {
return execa(
'tsc',
[
'--project', 'tsconfig.json',
'--rootDir', 'src',
'--outDir', 'built',
'--declaration', 'true',
'--emitDeclarationOnly', 'true',
],
{
stdout: process.stdout,
stderr: process.stderr,
},
);
}
async function watchSrc(): Promise<void> {
const localesWatcher = chokidarWatch(_localesDir, {
ignoreInitial: true,
});
localesWatcher.on('all', async (event, path) => {
if (!path.endsWith('.yml')) return;
console.log(`[${_package.name}] locales changed: ${event} ${path}`);
copyLocales();
await writeFrontendLocalesJson();
await generateLocaleInterface(_localesDir);
});
const plugins: Plugin[] = [{
name: 'gen-dts',
setup(build: PluginBuild) {
build.onStart(() => {
console.log(`[${_package.name}] detect changed...`);
});
build.onEnd(async (result: BuildResult) => {
if (result.errors.length > 0) {
console.error(`[${_package.name}] watch build failed:`, result);
return;
}
await buildDts();
});
},
}];
console.log(`[${_package.name}] start watching...`);
const context = await esbuild.context({ ...options, plugins });
await context.watch();
await new Promise((resolve, reject) => {
process.on('SIGHUP', resolve);
process.on('SIGINT', resolve);
process.on('SIGTERM', resolve);
process.on('uncaughtException', reject);
process.on('exit', resolve);
}).finally(async () => {
await context.dispose();
await localesWatcher.close();
console.log(`[${_package.name}] finish watching.`);
});
}

Some files were not shown because too many files have changed in this diff Show More