Merge pull request #16916 from misskey-dev/develop

Release: 2025.12.0
This commit is contained in:
misskey-release-bot[bot] 2025-12-06 12:22:58 +00:00 committed by GitHub
commit e40c84f31d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
208 changed files with 6073 additions and 4178 deletions

View File

@ -110,10 +110,10 @@ port: 3000
# Changes how the server interpret the origin IP of the request. # Changes how the server interpret the origin IP of the request.
# #
# Any format supported by Fastify is accepted. # Any format supported by Fastify is accepted.
# Default: trust all proxies (i.e. trustProxy: true) # Default: do not trust any proxies (i.e. trustProxy: false)
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy # See: https://fastify.dev/docs/latest/reference/server/#trustproxy
# #
# trustProxy: 1 # trustProxy: false
# ┌──────────────────────────┐ # ┌──────────────────────────┐
#───┘ PostgreSQL configuration └──────────────────────────────── #───┘ PostgreSQL configuration └────────────────────────────────

View File

@ -5,7 +5,7 @@
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {
"version": "24.10.0" "version": "22.15.0"
}, },
"ghcr.io/devcontainers-extra/features/pnpm:2": { "ghcr.io/devcontainers-extra/features/pnpm:2": {
"version": "10.10.0" "version": "10.10.0"

View File

@ -0,0 +1,87 @@
# this name is used in report-backend-memory.yml so be careful when change name
name: Get backend memory usage
on:
pull_request:
branches:
- master
- develop
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/workflows/get-backend-memory.yml
jobs:
get-memory-usage:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
memory-json-name: [memory-base.json, memory-head.json]
include:
- memory-json-name: memory-base.json
ref: ${{ github.base_ref }}
- memory-json-name: memory-head.json
ref: refs/pull/${{ github.event.number }}/merge
services:
postgres:
image: postgres:18
ports:
- 54312:5432
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:7
ports:
- 56312:6379
steps:
- uses: actions/checkout@v4.3.0
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Copy Configure
run: cp .github/misskey/test.yml .config/default.yml
- name: Compile Configure
run: pnpm compile-config
- name: Build
run: pnpm build
- name: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
run: |
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
save-pr-number:
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Save PR number
env:
PR_NUMBER: ${{ github.event.number }}
run: |
echo "$PR_NUMBER" > ./pr_number
- uses: actions/upload-artifact@v4
with:
name: memory-artifact-pr-number
path: pr_number

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

@ -0,0 +1,122 @@
name: Report backend memory
on:
workflow_run:
types: [completed]
workflows:
- Get backend memory usage # get-backend-memory.yml
jobs:
compare-memory:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
pull-requests: write
steps:
- name: Download artifact
uses: actions/github-script@v7.1.0
with:
script: |
const fs = require('fs');
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("memory-artifact-") || artifact.name == "memory-artifact"
});
await Promise.all(matchArtifacts.map(async (artifact) => {
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data));
}));
- name: Extract all artifacts
run: |
find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';'
ls -la artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Output base
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Compare memory usage
id: compare
run: |
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
BASE_RSS=$(echo "$BASE_MEMORY" | jq -r '.memory.rss // 0')
HEAD_RSS=$(echo "$HEAD_MEMORY" | jq -r '.memory.rss // 0')
# Calculate difference
if [ "$BASE_RSS" -gt 0 ] && [ "$HEAD_RSS" -gt 0 ]; then
DIFF=$((HEAD_RSS - BASE_RSS))
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE_RSS" | bc)
# Convert to MB for readability
BASE_MB=$(echo "scale=2; $BASE_RSS / 1048576" | bc)
HEAD_MB=$(echo "scale=2; $HEAD_RSS / 1048576" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1048576" | bc)
echo "base_mb=$BASE_MB" >> "$GITHUB_OUTPUT"
echo "head_mb=$HEAD_MB" >> "$GITHUB_OUTPUT"
echo "diff_mb=$DIFF_MB" >> "$GITHUB_OUTPUT"
echo "diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT"
echo "has_data=true" >> "$GITHUB_OUTPUT"
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$DIFF_PERCENT > 5" | bc)" -eq 1 ]; then
echo "significant_increase=true" >> "$GITHUB_OUTPUT"
else
echo "significant_increase=false" >> "$GITHUB_OUTPUT"
fi
else
echo "has_data=false" >> "$GITHUB_OUTPUT"
fi
- id: build-comment
name: Build memory comment
run: |
HEADER="## Backend Memory Usage Comparison"
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
echo "$HEADER" > ./output.md
echo >> ./output.md
if [ "${{ steps.compare.outputs.has_data }}" == "true" ]; then
echo "| Metric | base | head | Diff |" >> ./output.md
echo "|--------|------|------|------|" >> ./output.md
echo "| RSS | ${{ steps.compare.outputs.base_mb }} MB | ${{ steps.compare.outputs.head_mb }} MB | ${{ steps.compare.outputs.diff_mb }} MB (${{ steps.compare.outputs.diff_percent }}%) |" >> ./output.md
echo >> ./output.md
if [ "${{ steps.compare.outputs.significant_increase }}" == "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi
else
echo "Could not retrieve memory usage data." >> ./output.md
echo >> ./output.md
fi
echo "$FOOTER" >> ./output.md
- uses: thollander/actions-comment-pull-request@v2
with:
pr_number: ${{ steps.load-pr-num.outputs.pr-number }}
comment_tag: show_memory_diff
filePath: ./output.md
- name: Tell error to PR
uses: thollander/actions-comment-pull-request@v2
if: failure() && steps.load-pr-num.outputs.pr-number
with:
pr_number: ${{ steps.load-pr-num.outputs.pr-number }}
comment_tag: show_memory_diff_error
message: |
An error occurred while comparing backend memory usage. See [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.

View File

@ -1 +1 @@
24.10.0 22.15.0

View File

@ -3,6 +3,7 @@
"**/node_modules": true "**/node_modules": true
}, },
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.associations": { "files.associations": {
"*.test.ts": "typescript" "*.test.ts": "typescript"
}, },

View File

@ -1,3 +1,17 @@
## 2025.12.0
### Note
- configの`trustProxy`のデフォルト値を`false`に変更しました。アップデート前に現在のconfigをご確認の上、必要に応じて値を変更してください。
### Client
- Fix: stacking router viewで連続して戻る操作を行うと何も表示されなくなる問題を修正
### Server
- Enhance: メモリ使用量を削減しました
- Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上
- Enhance: 依存関係の更新
- Fix: セキュリティに関する修正
## 2025.11.1 ## 2025.11.1
### Client ### Client

View File

@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4 # syntax = docker/dockerfile:1.4
ARG NODE_VERSION=24.10.0-bookworm ARG NODE_VERSION=22.15.0-bookworm
# build assets & compile TypeScript # build assets & compile TypeScript
@ -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

@ -24,6 +24,8 @@
<a href="https://www.patreon.com/syuilo"> <a href="https://www.patreon.com/syuilo">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a> <img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a>
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/misskey-dev/misskey)
</div> </div>
## Thanks ## Thanks

View File

@ -128,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"
@ -143,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"
@ -319,10 +319,10 @@ remoteUserCaution: "Para el usuario remoto, la información está incompleta"
activity: "Actividad" activity: "Actividad"
images: "Imágenes" images: "Imágenes"
image: "Imágenes" image: "Imágenes"
birthday: "Fecha de nacimiento" birthday: "Cumpleaños"
yearsOld: "{age} años" yearsOld: "{age} años"
registeredDate: "Fecha de registro" registeredDate: "Fecha de registro"
location: "Lugar" location: "Ubicación"
theme: "Tema" theme: "Tema"
themeForLightMode: "Tema para usar en Modo Linterna" themeForLightMode: "Tema para usar en Modo Linterna"
themeForDarkMode: "Tema para usar en Modo Oscuro" themeForDarkMode: "Tema para usar en Modo Oscuro"
@ -353,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."
@ -579,7 +579,7 @@ objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir "
s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio." s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio."
serverLogs: "Registros del servidor" serverLogs: "Registros del servidor"
deleteAll: "Eliminar todos" deleteAll: "Eliminar todos"
showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" showFixedPostForm: "Visualizar la ventana de publicación en la parte superior de la línea de tiempo."
showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)" showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)"
withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo" withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo"
newNoteRecived: "Tienes una nota nueva" newNoteRecived: "Tienes una nota nueva"
@ -706,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"
@ -715,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"
@ -747,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"
@ -844,7 +844,7 @@ jumpToSpecifiedDate: "Saltar a una fecha específica"
showingPastTimeline: "Mostrar líneas de tiempo antiguas" showingPastTimeline: "Mostrar líneas de tiempo antiguas"
clear: "Limpiar" clear: "Limpiar"
markAllAsRead: "Marcar todo como leído" markAllAsRead: "Marcar todo como leído"
goBack: "Deseleccionar" goBack: "Anterior"
unlikeConfirm: "¿Quitar como favorito?" unlikeConfirm: "¿Quitar como favorito?"
fullView: "Vista completa" fullView: "Vista completa"
quitFullView: "quitar vista completa" quitFullView: "quitar vista completa"
@ -1203,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"
@ -1412,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"
@ -1427,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"
@ -1456,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"
@ -1511,7 +1511,7 @@ _emojiPalette:
palettes: "Paleta\n" palettes: "Paleta\n"
enableSyncBetweenDevicesForPalettes: "Activar la sincronización de paletas entre dispositivos" enableSyncBetweenDevicesForPalettes: "Activar la sincronización de paletas entre dispositivos"
paletteForMain: "Paleta principal" paletteForMain: "Paleta principal"
paletteForReaction: "Paleta de reacción" paletteForReaction: "Paleta utilizada para las reacciones"
_settings: _settings:
driveBanner: "Puedes gestionar y configurar la unidad, comprobar su uso y configurar los ajustes de carga de archivos." driveBanner: "Puedes gestionar y configurar la unidad, comprobar su uso y configurar los ajustes de carga de archivos."
pluginBanner: "Puedes ampliar las funciones del cliente con plugins. Puedes instalar plugins, configurarlos y gestionarlos individualmente." pluginBanner: "Puedes ampliar las funciones del cliente con plugins. Puedes instalar plugins, configurarlos y gestionarlos individualmente."
@ -1523,7 +1523,7 @@ _settings:
accountData: "Datos de la cuenta" accountData: "Datos de la cuenta"
accountDataBanner: "Exportación e importación para gestionar los datos de la cuenta." accountDataBanner: "Exportación e importación para gestionar los datos de la cuenta."
muteAndBlockBanner: "Puedes configurar y gestionar ajustes para ocultar contenidos y restringir acciones a usuarios específicos." muteAndBlockBanner: "Puedes configurar y gestionar ajustes para ocultar contenidos y restringir acciones a usuarios específicos."
accessibilityBanner: "Puedes personalizar los visuales y el comportamiento del cliente, y configurar los ajustes para optimizar el uso." accessibilityBanner: "Puedes personalizar el aspecto y el comportamiento del cliente y configurar los ajustes para optimizar su uso."
privacyBanner: "Puedes configurar opciones relacionadas con la privacidad de la cuenta, como la visibilidad del contenido, la posibilidad de descubrir la cuenta y la aprobación de seguimiento." privacyBanner: "Puedes configurar opciones relacionadas con la privacidad de la cuenta, como la visibilidad del contenido, la posibilidad de descubrir la cuenta y la aprobación de seguimiento."
securityBanner: "Puedes configurar opciones relacionadas con la seguridad de la cuenta, como la contraseña, los métodos de inicio de sesión, las aplicaciones de autenticación y Passkeys." securityBanner: "Puedes configurar opciones relacionadas con la seguridad de la cuenta, como la contraseña, los métodos de inicio de sesión, las aplicaciones de autenticación y Passkeys."
preferencesBanner: "Puedes configurar el comportamiento general del cliente según tus preferencias." preferencesBanner: "Puedes configurar el comportamiento general del cliente según tus preferencias."
@ -1540,7 +1540,7 @@ _settings:
ifOff: "Si está desactivado" ifOff: "Si está desactivado"
enableSyncThemesBetweenDevices: "Sincronizar los temas instalados entre dispositivos." enableSyncThemesBetweenDevices: "Sincronizar los temas instalados entre dispositivos."
enablePullToRefresh: "Tirar para actualizar" enablePullToRefresh: "Tirar para actualizar"
enablePullToRefresh_description: "Si utiliza un ratón, arrastre mientras pulsa la rueda de desplazamiento." enablePullToRefresh_description: "Si utilizas un ratón, arrastra mientras pulsas la rueda de desplazamiento."
realtimeMode_description: "Establece una conexión con el servidor y actualiza el contenido en tiempo real. Esto puede aumentar el tráfico y el consumo de memoria." realtimeMode_description: "Establece una conexión con el servidor y actualiza el contenido en tiempo real. Esto puede aumentar el tráfico y el consumo de memoria."
contentsUpdateFrequency: "Frecuencia de adquisición del contenido." contentsUpdateFrequency: "Frecuencia de adquisición del contenido."
contentsUpdateFrequency_description: "Cuanto mayor sea el valor, más se actualiza el contenido, pero disminuye el rendimiento y aumenta el tráfico y el consumo de memoria." contentsUpdateFrequency_description: "Cuanto mayor sea el valor, más se actualiza el contenido, pero disminuye el rendimiento y aumenta el tráfico y el consumo de memoria."
@ -1685,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)"
@ -2156,7 +2156,7 @@ _accountDelete:
started: "El proceso de eliminación ha comenzado." started: "El proceso de eliminación ha comenzado."
inProgress: "La eliminación está en proceso." inProgress: "La eliminación está en proceso."
_ad: _ad:
back: "Deseleccionar" back: "Anterior"
reduceFrequencyOfThisAd: "Mostrar menos este anuncio." reduceFrequencyOfThisAd: "Mostrar menos este anuncio."
hide: "No mostrar" hide: "No mostrar"
timezoneinfo: "El día de la semana está determidado por la zona horaria del servidor." timezoneinfo: "El día de la semana está determidado por la zona horaria del servidor."
@ -2299,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)"
@ -2610,10 +2610,10 @@ _profile:
name: "Nombre" name: "Nombre"
username: "Nombre de usuario" username: "Nombre de usuario"
description: "Descripción" description: "Descripción"
youCanIncludeHashtags: "Puedes añadir hashtags" youCanIncludeHashtags: "También puedes incluir hashtags en tu biografía"
metadata: "información adicional" metadata: "información adicional"
metadataEdit: "Editar información adicional" metadataEdit: "Editar información adicional"
metadataDescription: "Muestra la información adicional en el perfil" metadataDescription: "Usando esto puedes mostrar campos de información adicionales en tu perfil."
metadataLabel: "Etiqueta" metadataLabel: "Etiqueta"
metadataContent: "Contenido" metadataContent: "Contenido"
changeAvatar: "Cambiar avatar" changeAvatar: "Cambiar avatar"
@ -2771,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: "Allegati"
download: "Scarica" download: "Scarica"
driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?" driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?"
unfollowConfirm: "Vuoi davvero togliere il Following a {name}?" unfollowConfirm: "Vuoi davvero togliere il Following a {name}?"
cancelFollowRequestConfirm: "Vuoi annullare la tua richiesta di follow inviata a {name}?"
rejectFollowRequestConfirm: "Vuoi rifiutare la richiesta di follow ricevuta da {name}?"
exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive." exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive."
importRequested: "Hai richiesto un'importazione. Potrebbe richiedere un po' di tempo." importRequested: "Hai richiesto un'importazione. Potrebbe richiedere un po' di tempo."
lists: "Liste" lists: "Liste"
@ -2350,13 +2352,13 @@ _ago:
yearsAgo: "{n} anni fa" yearsAgo: "{n} anni fa"
invalid: "Niente da visualizzare" invalid: "Niente da visualizzare"
_timeIn: _timeIn:
seconds: "Dopo {n} secondi" seconds: "Tra {n} secondi"
minutes: "Dopo {n} minuti" minutes: "Tra {n} minuti"
hours: "Dopo {n} ore" hours: "Tra {n} ore"
days: "Dopo {n} giorni" days: "Tra {n} giorni"
weeks: "Dopo {n} settimane" weeks: "Tra {n} settimane"
months: "Dopo {n} mesi" months: "Tra {n} mesi"
years: "Dopo {n} anni" years: "Tra {n} anni"
_time: _time:
second: "s" second: "s"
minute: "min" minute: "min"

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

@ -877,7 +877,7 @@ noInquiryUrlWarning: "尚未设置联络地址。"
noBotProtectionWarning: "尚未设置 Bot 防御。" noBotProtectionWarning: "尚未设置 Bot 防御。"
configure: "设置" configure: "设置"
postToGallery: "创建新图集" postToGallery: "创建新图集"
postToHashtag: "投稿到这个标签" postToHashtag: "发布至该话题"
gallery: "图集" gallery: "图集"
recentPosts: "最新发布" recentPosts: "最新发布"
popularPosts: "热门投稿" popularPosts: "热门投稿"
@ -3146,7 +3146,7 @@ _selfXssPrevention:
description3: "详情请看这里。{link}" description3: "详情请看这里。{link}"
_followRequest: _followRequest:
recieved: "收到的请求" recieved: "收到的请求"
sent: "发送的请求" sent: "发送的请求"
_remoteLookupErrors: _remoteLookupErrors:
_federationNotAllowed: _federationNotAllowed:
title: "无法与此服务器通信" title: "无法与此服务器通信"

View File

@ -1,33 +1,36 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.11.1", "version": "2025.12.0",
"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.24.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": {
"compile-config": "cd packages/backend && pnpm compile-config",
"build-pre": "node ./scripts/build-pre.js", "build-pre": "node ./scripts/build-pre.js",
"build-assets": "node ./scripts/build-assets.mjs", "build-assets": "node ./scripts/build-assets.mjs",
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook", "build-storybook": "pnpm --filter frontend build-storybook",
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start": "pnpm check:connect && cd packages/backend && pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "cd packages/backend && node --inspect ./built/boot/entry.js", "start:inspect": "cd packages/backend && pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"cli": "cd packages/backend && pnpm cli", "cli": "cd packages/backend && pnpm cli",
"init": "pnpm migrate", "init": "pnpm migrate",
"migrate": "cd packages/backend && pnpm migrate", "migrate": "cd packages/backend && pnpm migrate",
@ -74,12 +77,12 @@
"@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.24.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

@ -3,12 +3,17 @@
"jsc": { "jsc": {
"parser": { "parser": {
"syntax": "typescript", "syntax": "typescript",
"jsx": true,
"dynamicImport": true, "dynamicImport": true,
"decorators": true "decorators": true
}, },
"transform": { "transform": {
"legacyDecorator": true, "legacyDecorator": true,
"decoratorMetadata": true "decoratorMetadata": true,
"react": {
"runtime": "automatic",
"importSource": "@kitajs/html"
}
}, },
"experimental": { "experimental": {
"keepImportAssertions": true "keepImportAssertions": true

View File

@ -0,0 +1,46 @@
(async () => {
const msg = document.getElementById('msg');
const successText = `\nSuccess Flush! <a href="/">Back to Misskey</a>\n成功しました。<a href="/">Misskeyを開き直してください。</a>`;
if (!document.cookie) {
message('Your site data is fully cleared by your browser.');
message(successText);
} else {
message('Your browser does not support Clear-Site-Data header. Start opportunistic flushing.');
try {
localStorage.clear();
message('localStorage cleared.');
const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => {
const delidb = indexedDB.deleteDatabase(name);
delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`));
delidb.onerror = e => rej(e)
}));
await Promise.all(idbPromises);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('clear');
await navigator.serviceWorker.getRegistrations()
.then(registrations => {
return Promise.all(registrations.map(registration => registration.unregister()));
})
.catch(e => { throw new Error(e) });
}
message(successText);
} catch (e) {
message(`\n${e}\n\nFlush Failed. <a href="/flush">Please retry.</a>\n失敗しました。<a href="/flush">もう一度試してみてください。</a>`);
message(`\nIf you retry more than 3 times, try manually clearing the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを手動で消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`)
console.error(e);
setTimeout(() => {
location = '/';
}, 10000)
}
}
function message(text) {
msg.insertAdjacentHTML('beforeend', `<p>[${(new Date()).toString()}] ${text.replace(/\n/g,'<br>')}</p>`)
}
})();

View File

@ -0,0 +1,35 @@
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
background: #fff;
}
#a {
display: block;
}
#banner {
background-size: cover;
background-position: center center;
}
#title {
display: inline-block;
margin: 24px;
padding: 0.5em 0.8em;
color: #fff;
background: rgba(0, 0, 0, 0.5);
font-weight: bold;
font-size: 1.3em;
}
#content {
overflow: auto;
color: #353c3e;
}
#description {
margin: 24px;
}

View File

@ -205,7 +205,7 @@ module.exports = {
// Whether to use watchman for file crawling // Whether to use watchman for file crawling
// watchman: true, // watchman: true,
extensionsToTreatAsEsm: ['.ts'], extensionsToTreatAsEsm: ['.ts', '.tsx'],
testTimeout: 60000, testTimeout: 60000,

View File

@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"allowSyntheticDefaultImports": true
},
"exclude": [
"node_modules",
"jspm_packages",
"tmp",
"temp"
]
}

View File

@ -3,14 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js"; const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
export class CompositeNoteIndex1745378064470 { export class CompositeNoteIndex1745378064470 {
name = 'CompositeNoteIndex1745378064470'; name = 'CompositeNoteIndex1745378064470';
transaction = isConcurrentIndexMigrationEnabled() ? false : undefined; transaction = isConcurrentIndexMigrationEnabled ? false : undefined;
async up(queryRunner) { async up(queryRunner) {
const concurrently = isConcurrentIndexMigrationEnabled(); const concurrently = isConcurrentIndexMigrationEnabled;
if (concurrently) { if (concurrently) {
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`); const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
@ -29,7 +29,7 @@ export class CompositeNoteIndex1745378064470 {
} }
async down(queryRunner) { async down(queryRunner) {
const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : ''; const mayConcurrently = isConcurrentIndexMigrationEnabled ? 'CONCURRENTLY' : '';
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
} }

View File

@ -3,17 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import {loadConfig} from "./js/migration-config.js";
export class MigrateSomeConfigFileSettingsToMeta1746949539915 { export class MigrateSomeConfigFileSettingsToMeta1746949539915 {
name = 'MigrateSomeConfigFileSettingsToMeta1746949539915' name = 'MigrateSomeConfigFileSettingsToMeta1746949539915'
async up(queryRunner) { async up(queryRunner) {
const config = loadConfig();
// $1 cannot be used in ALTER TABLE queries // $1 cannot be used in ALTER TABLE queries
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT ${config.proxyRemoteFiles}`); await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT ${config.signToActivityPubGet}`); await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT ${!config.disallowExternalApRedirect}`); await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT TRUE`);
} }
async down(queryRunner) { async down(queryRunner) {

View File

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { path as configYamlPath } from '../../built/config.js';
import * as yaml from 'js-yaml';
import fs from "node:fs";
export function isConcurrentIndexMigrationEnabled() {
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
}
let loadedConfigCache = undefined;
function loadConfigInternal() {
const config = yaml.load(fs.readFileSync(configYamlPath, 'utf-8'));
return {
disallowExternalApRedirect: Boolean(config.disallowExternalApRedirect ?? false),
proxyRemoteFiles: Boolean(config.proxyRemoteFiles ?? false),
signToActivityPubGet: Boolean(config.signToActivityPubGet ?? true),
}
}
export function loadConfig() {
if (loadedConfigCache === undefined) {
loadedConfigCache = loadConfigInternal();
}
return loadedConfigCache;
}

View File

@ -1,7 +1,8 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { loadConfig } from './built/config.js'; import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js'; import { entities } from './built/postgres.js';
import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js";
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
const config = loadConfig(); const config = loadConfig();
@ -15,5 +16,5 @@ export default new DataSource({
extra: config.db.extra, extra: config.db.extra,
entities: entities, entities: entities,
migrations: ['migration/*.js'], migrations: ['migration/*.js'],
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all', migrationsTransactionMode: isConcurrentIndexMigrationEnabled ? 'each' : 'all',
}); });

View File

@ -7,50 +7,51 @@
"node": "^22.15.0 || ^24.10.0" "node": "^22.15.0 || ^24.10.0"
}, },
"scripts": { "scripts": {
"start": "node ./built/boot/entry.js", "start": "pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "node --inspect ./built/boot/entry.js", "start:inspect": "pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js", "start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js", "migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js", "revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
"cli": "node ./built/boot/cli.js", "cli": "pnpm compile-config && node ./built/boot/cli.js",
"check:connect": "node ./scripts/check_connect.js", "check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
"build": "swc src -d built -D --strip-leading-paths", "build": "swc src -d built -D --strip-leading-paths",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths", "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths", "watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node ./scripts/watch.mjs", "watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start", "restart": "pnpm build && pnpm start",
"dev": "node ./scripts/dev.mjs", "dev": "pnpm compile-config && node ./scripts/dev.mjs",
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint", "lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs", "jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs", "jest:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs", "jest:fed": "pnpm compile-config && node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs", "jest-and-coverage": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs", "jest-and-coverage:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache", "jest-clear": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --clearCache",
"test": "pnpm jest", "test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test:fed": "pnpm jest:fed", "test:fed": "pnpm jest:fed",
"test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"check-migrations": "node scripts/check_migrations_clean.js", "check-migrations": "node scripts/check_migrations_clean.js",
"generate-api-json": "node ./scripts/generate_api_json.js" "generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-android-arm64": "1.3.11", "@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.15.2", "@swc/core-darwin-arm64": "1.15.3",
"@swc/core-darwin-x64": "1.15.2", "@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.2", "@swc/core-linux-arm-gnueabihf": "1.15.3",
"@swc/core-linux-arm64-gnu": "1.15.2", "@swc/core-linux-arm64-gnu": "1.15.3",
"@swc/core-linux-arm64-musl": "1.15.2", "@swc/core-linux-arm64-musl": "1.15.3",
"@swc/core-linux-x64-gnu": "1.15.2", "@swc/core-linux-x64-gnu": "1.15.3",
"@swc/core-linux-x64-musl": "1.15.2", "@swc/core-linux-x64-musl": "1.15.3",
"@swc/core-win32-arm64-msvc": "1.15.2", "@swc/core-win32-arm64-msvc": "1.15.3",
"@swc/core-win32-ia32-msvc": "1.15.2", "@swc/core-win32-ia32-msvc": "1.15.3",
"@swc/core-win32-x64-msvc": "1.15.2", "@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,106 +71,100 @@
"utf-8-validate": "6.0.5" "utf-8-validate": "6.0.5"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.936.0", "@aws-sdk/client-s3": "3.940.0",
"@aws-sdk/lib-storage": "3.936.0", "@aws-sdk/lib-storage": "3.940.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", "@kitajs/html": "4.2.11",
"@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.82", "@napi-rs/canvas": "0.1.83",
"@nestjs/common": "11.1.9", "@nestjs/common": "11.1.9",
"@nestjs/core": "11.1.9", "@nestjs/core": "11.1.9",
"@nestjs/testing": "11.1.9", "@nestjs/testing": "11.1.9",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@sentry/node": "10.26.0", "@sentry/node": "10.27.0",
"@sentry/profiling-node": "10.26.0", "@sentry/profiling-node": "10.27.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.2", "@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.1",
"bullmq": "5.63.2", "bullmq": "5.65.0",
"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.1.1", "file-type": "21.1.1",
"fluent-ffmpeg": "2.1.3", "fluent-ffmpeg": "2.1.3",
"form-data": "4.0.5", "form-data": "4.0.5",
"got": "14.6.4", "got": "14.6.5",
"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",
"i18n": "workspace:*",
"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.3.0",
"is-svg": "5.1.0", "is-svg": "6.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", "mime-types": "3.0.2",
"mime-types": "2.1.35",
"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",
"nodemailer": "7.0.10", "node-html-parser": "7.0.1",
"nodemailer": "7.0.11",
"nsfwjs": "4.2.0", "nsfwjs": "4.2.0",
"oauth": "0.10.2", "oauth": "0.10.2",
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
"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.1",
"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",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"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 +177,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,33 +185,29 @@
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "29.7.0", "@jest/globals": "29.7.0",
"@nestjs/platform-express": "10.4.20", "@kitajs/ts-html-plugin": "4.1.3",
"@sentry/vue": "10.26.0", "@nestjs/platform-express": "11.1.9",
"@sentry/vue": "10.27.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/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.1", "@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",
"@types/pg": "8.15.6", "@types/pg": "8.15.6",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.6", "@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5", "@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6", "@types/ratelimiter": "3.4.6",
@ -224,25 +215,28 @@
"@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.47.0", "@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.47.0", "@typescript-eslint/parser": "8.48.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",
"jest-util": "29.7.0",
"js-yaml": "4.1.1",
"nodemon": "3.1.11", "nodemon": "3.1.11",
"pid-port": "1.0.2", "pid-port": "2.0.0",
"simple-oauth2": "5.1.0", "simple-oauth2": "5.1.0",
"supertest": "7.1.4" "supertest": "7.1.4",
"vite": "7.2.4"
} }
} }

View File

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* YAMLファイルをJSONファイルに変換するスクリプト
* ビルド前に実行しランタイムにjs-yamlを含まないようにする
*/
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const configDir = resolve(_dirname, '../../../.config');
const OUTPUT_PATH = resolve(_dirname, '../../../built/.config.json');
// TODO: yamlのパースに失敗したときのエラーハンドリング
/**
* YAMLファイルをJSONファイルに変換
* @param {string} ymlPath - YAMLファイルのパス
*/
function yamlToJson(ymlPath) {
if (!fs.existsSync(ymlPath)) {
console.warn(`YAML file not found: ${ymlPath}`);
return;
}
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify({
'_NOTE_': 'This file is auto-generated from YAML file. DO NOT EDIT.',
...jsonContent,
}), 'utf-8');
}
if (process.env.MISSKEY_CONFIG_YML) {
const customYmlPath = resolve(configDir, process.env.MISSKEY_CONFIG_YML);
yamlToJson(customYmlPath);
} else {
yamlToJson(resolve(configDir, process.env.NODE_ENV === 'test' ? 'test.yml' : 'default.yml'));
}
console.log('Configuration compiled ✓');

View File

@ -42,7 +42,7 @@ async function killProc() {
'./node_modules/nodemon/bin/nodemon.js', './node_modules/nodemon/bin/nodemon.js',
[ [
'-w', 'src', '-w', 'src',
'-e', 'ts,js,mjs,cjs,json,pug', '-e', 'ts,js,mjs,cjs,tsx,json,pug',
'--exec', 'pnpm', 'run', 'build', '--exec', 'pnpm', 'run', 'build',
], ],
{ {

View File

@ -0,0 +1,152 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* This script starts the Misskey backend server, waits for it to be ready,
* measures memory usage, and outputs the result as JSON.
*
* Usage: node scripts/measure-memory.mjs
*/
import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
async function measureMemory() {
const startTime = Date.now();
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), [], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'test',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let serverReady = false;
// Listen for the 'ok' message from the server indicating it's ready
serverProcess.on('message', (message) => {
if (message === 'ok') {
serverReady = true;
}
});
// Handle server output
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
// Get memory usage from the server process via /proc
const pid = serverProcess.pid;
let memoryInfo;
try {
const fs = await import('node:fs/promises');
// Read /proc/[pid]/status for detailed memory info
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
memoryInfo = {
rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
};
} catch (err) {
// Fallback: use ps command
process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
const { execSync } = await import('node:child_process');
try {
const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
const rssKb = parseInt(ps.trim(), 10);
memoryInfo = {
rss: rssKb * 1024,
heapUsed: null,
vmSize: null,
};
} catch {
memoryInfo = {
rss: null,
heapUsed: null,
vmSize: null,
error: 'Could not measure memory',
};
}
}
// Stop the server
serverProcess.kill('SIGTERM');
// Wait for process to exit
let exited = false;
await new Promise((resolve) => {
serverProcess.on('exit', () => {
exited = true;
resolve(undefined);
});
// Force kill after 10 seconds if not exited
setTimeout(10000).then(() => {
if (!exited) {
serverProcess.kill('SIGKILL');
}
resolve(undefined);
});
});
const result = {
timestamp: new Date().toISOString(),
startupTimeMs: startupTime,
memory: memoryInfo,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
measureMemory().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
}));
process.exit(1);
});

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

@ -6,11 +6,11 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import { type FastifyServerOptions } from 'fastify'; import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node'; import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue'; import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis'; import type { RedisOptions } from 'ioredis';
import type { ManifestChunk } from 'vite';
type RedisOptionsSource = Partial<RedisOptions> & { type RedisOptionsSource = Partial<RedisOptions> & {
host: string; host: string;
@ -187,9 +187,9 @@ export type Config = {
authUrl: string; authUrl: string;
driveUrl: string; driveUrl: string;
userAgent: string; userAgent: string;
frontendEntry: { file: string | null }; frontendEntry: ManifestChunk;
frontendManifestExists: boolean; frontendManifestExists: boolean;
frontendEmbedEntry: { file: string | null }; frontendEmbedEntry: ManifestChunk;
frontendEmbedManifestExists: boolean; frontendEmbedManifestExists: boolean;
mediaProxy: string; mediaProxy: string;
externalMediaProxyEnabled: boolean; externalMediaProxyEnabled: boolean;
@ -217,21 +217,15 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
/** const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json');
* Path of configuration directory
*/
const dir = `${_dirname}/../../../.config`;
/** export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json');
* Path of configuration file
*/
export const path = process.env.MISSKEY_CONFIG_YML
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
: process.env.NODE_ENV === 'test'
? resolve(dir, 'test.yml')
: resolve(dir, 'default.yml');
export function loadConfig(): Config { export function loadConfig(): Config {
if (!fs.existsSync(compiledConfigFilePath)) {
throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.');
}
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
@ -243,7 +237,7 @@ export function loadConfig(): Config {
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: null } }; : { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source;
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
const version = meta.version; const version = meta.version;

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 ファイルをクライアント用とサーバー用で分けたい

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

@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js'; import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit {
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore; const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null) if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) {
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
packedNote.visibility = 'followers'; packedNote.visibility = 'followers';
} }
} }
@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit {
if (!hide) { if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore; const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null) if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
hide = true; hide = true;
} }
} }

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

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const ESCAPE_LOOKUP = {
'&': '\\u0026',
'>': '\\u003e',
'<': '\\u003c',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
} as Record<string, string>;
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
export function htmlSafeJsonStringify(obj: any): string {
return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]);
}

View File

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
*
* @param hiddenBefore 負の値: 作成からの経過秒数正の値: UNIXタイムスタンプ秒null:
* @param createdAt ISO 8601 Date
* @returns true
*/
export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean {
if (hiddenBefore == null) {
return false;
}
const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime();
if (hiddenBefore <= 0) {
// 負の値: 作成からの経過時間(秒)で判定
const elapsedSeconds = (Date.now() - createdAtTime) / 1000;
const hideAfterSeconds = Math.abs(hiddenBefore);
return elapsedSeconds >= hideAfterSeconds;
} else {
// 正の値: 絶対的なタイムスタンプ(秒)で判定
const createdAtSeconds = createdAtTime / 1000;
return createdAtSeconds <= hiddenBefore;
}
}

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

@ -5,21 +5,20 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { Inject, Injectable, StreamableFile } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import type { MiPoll } from '@/models/Poll.js'; import type { MiPoll } from '@/models/Poll.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 { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@ -43,6 +42,7 @@ export class ExportClipsProcessorService {
private driveService: DriveService, private driveService: DriveService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService, private idService: IdService,
private notificationService: NotificationService, private notificationService: NotificationService,
) { ) {
@ -100,16 +100,16 @@ export class ExportClipsProcessorService {
}); });
while (true) { while (true) {
const clips = await this.clipsRepository.find({ const query = this.clipsRepository.createQueryBuilder('clip')
where: { .where('clip.userId = :userId', { userId: user.id })
userId: user.id, .orderBy('clip.id', 'ASC')
...(cursor ? { id: MoreThan(cursor) } : {}), .take(100);
},
take: 100, if (cursor) {
order: { query.andWhere('clip.id > :cursor', { cursor });
id: 1, }
},
}); const clips = await query.getMany();
if (clips.length === 0) { if (clips.length === 0) {
job.updateProgress(100); job.updateProgress(100);
@ -124,7 +124,7 @@ export class ExportClipsProcessorService {
const isFirst = exportedClipsCount === 0; const isFirst = exportedClipsCount === 0;
await writer.write(isFirst ? content : ',\n' + content); await writer.write(isFirst ? content : ',\n' + content);
await this.processClipNotes(writer, clip.id); await this.processClipNotes(writer, clip.id, user.id);
await writer.write(']}'); await writer.write(']}');
exportedClipsCount++; exportedClipsCount++;
@ -134,22 +134,25 @@ export class ExportClipsProcessorService {
} }
} }
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> { async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise<void> {
let exportedClipNotesCount = 0; let exportedClipNotesCount = 0;
let cursor: MiClipNote['id'] | null = null; let cursor: MiClipNote['id'] | null = null;
while (true) { while (true) {
const clipNotes = await this.clipNotesRepository.find({ const query = this.clipNotesRepository.createQueryBuilder('clipNote')
where: { .leftJoinAndSelect('clipNote.note', 'note')
clipId, .leftJoinAndSelect('note.user', 'user')
...(cursor ? { id: MoreThan(cursor) } : {}), .where('clipNote.clipId = :clipId', { clipId })
}, .orderBy('clipNote.id', 'ASC')
take: 100, .take(100);
order: {
id: 1, if (cursor) {
}, query.andWhere('clipNote.id > :cursor', { cursor });
relations: ['note', 'note.user'], }
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
this.queryService.generateVisibilityQuery(query, { id: userId });
const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[];
if (clipNotes.length === 0) { if (clipNotes.length === 0) {
break; break;
@ -158,6 +161,11 @@ export class ExportClipsProcessorService {
cursor = clipNotes.at(-1)?.id ?? null; cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) { for (const clipNote of clipNotes) {
const noteCreatedAt = this.idService.parse(clipNote.note.id).date;
if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined; let poll: MiPoll | undefined;
if (clipNote.note.hasPoll) { if (clipNote.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id }); poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });

View File

@ -5,7 +5,6 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js'; import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js';
@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService {
private driveService: DriveService, private driveService: DriveService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService, private idService: IdService,
private notificationService: NotificationService, private notificationService: NotificationService,
) { ) {
@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService {
}); });
while (true) { while (true) {
const favorites = await this.noteFavoritesRepository.find({ const query = this.noteFavoritesRepository.createQueryBuilder('favorite')
where: { .leftJoinAndSelect('favorite.note', 'note')
userId: user.id, .leftJoinAndSelect('note.user', 'user')
...(cursor ? { id: MoreThan(cursor) } : {}), .where('favorite.userId = :userId', { userId: user.id })
}, .orderBy('favorite.id', 'ASC')
take: 100, .take(100);
order: {
id: 1, if (cursor) {
}, query.andWhere('favorite.id > :cursor', { cursor });
relations: ['note', 'note.user'], }
}) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
this.queryService.generateVisibilityQuery(query, { id: user.id });
const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
if (favorites.length === 0) { if (favorites.length === 0) {
job.updateProgress(100); job.updateProgress(100);
@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService {
cursor = favorites.at(-1)?.id ?? null; cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) { for (const favorite of favorites) {
const noteCreatedAt = this.idService.parse(favorite.note.id).date;
if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined; let poll: MiPoll | undefined;
if (favorite.note.hasPoll) { if (favorite.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id }); poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id });

View File

@ -25,6 +25,7 @@ import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js'; import { ClientServerService } from './web/ClientServerService.js';
import { HtmlTemplateService } from './web/HtmlTemplateService.js';
import { FeedService } from './web/FeedService.js'; import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js';
@ -58,6 +59,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
providers: [ providers: [
ClientServerService, ClientServerService,
ClientLoggerService, ClientLoggerService,
HtmlTemplateService,
FeedService, FeedService,
HealthServerService, HealthServerService,
UrlPreviewService, UrlPreviewService,

View File

@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
@bindThis @bindThis
public async launch(): Promise<void> { public async launch(): Promise<void> {
const fastify = Fastify({ const fastify = Fastify({
trustProxy: this.config.trustProxy ?? true, trustProxy: this.config.trustProxy ?? false,
logger: false, logger: false,
}); });
this.#fastify = fastify; this.#fastify = fastify;

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

@ -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

@ -6,18 +6,15 @@
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';
import oauth2Pkce from 'oauth2orize-pkce'; import oauth2Pkce from 'oauth2orize-pkce';
import fastifyCors from '@fastify/cors'; import fastifyCors from '@fastify/cors';
import fastifyView from '@fastify/view';
import pug from 'pug';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import fastifyExpress from '@fastify/express'; import fastifyExpress from '@fastify/express';
import { verifyChallenge } from 'pkce-challenge'; import { verifyChallenge } from 'pkce-challenge';
import { mf2 } from 'microformats-parser';
import { permissions as kinds } from 'misskey-js'; import { permissions as kinds } from 'misskey-js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -32,6 +29,8 @@ import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js';
import { OAuthPage } from '@/server/web/views/oauth.js';
import type { ServerResponse } from 'node:http'; import type { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
@ -98,6 +97,32 @@ interface ClientInformation {
logo: string | null; logo: string | null;
} }
function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: string): { name: string | null; logo: string | null; } {
let name: string | null = null;
let logo: string | null = null;
const hApp = doc.querySelector('.h-app');
if (hApp == null) return { name, logo };
const nameEl = hApp.querySelector('.p-name');
if (nameEl != null) {
const href = nameEl.attributes.href || nameEl.attributes.src;
if (href != null && new URL(href, baseUrl).toString() === new URL(id).toString()) {
name = nameEl.textContent.trim();
}
}
const logoEl = hApp.querySelector('.u-logo');
if (logoEl != null) {
const href = logoEl.attributes.href || logoEl.attributes.src;
if (href != null) {
logo = new URL(href, baseUrl).toString();
}
}
return { name, logo };
}
// https://indieauth.spec.indieweb.org/#client-information-discovery // https://indieauth.spec.indieweb.org/#client-information-discovery
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, // "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
// and if there is an [h-app] with a url property matching the client_id URL, // and if there is an [h-app] with a url property matching the client_id URL,
@ -120,24 +145,19 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
} }
const text = await res.text(); const text = await res.text();
const fragment = JSDOM.fragment(text); const doc = htmlParser.parse(`<div>${text}</div>`);
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href)); redirectUris.push(...[...doc.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;
if (text) { if (text) {
const microformats = mf2(text, { baseUrl: res.url }); const microformats = parseMicroformats(doc, res.url, id);
const correspondingProperties = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id)); if (typeof microformats.name === 'string') {
if (correspondingProperties) { name = microformats.name;
const nameProperty = correspondingProperties.properties.name?.[0];
if (typeof nameProperty === 'string') {
name = nameProperty;
}
const logoProperty = correspondingProperties.properties.logo?.[0];
if (typeof logoProperty === 'string') {
logo = logoProperty;
} }
if (typeof microformats.logo === 'string') {
logo = microformats.logo;
} }
} }
@ -253,6 +273,7 @@ export class OAuth2ProviderService {
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private cacheService: CacheService, private cacheService: CacheService,
loggerService: LoggerService, loggerService: LoggerService,
private htmlTemplateService: HtmlTemplateService,
) { ) {
this.#logger = loggerService.getLogger('oauth'); this.#logger = loggerService.getLogger('oauth');
@ -386,24 +407,16 @@ export class OAuth2ProviderService {
this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
reply.header('Cache-Control', 'no-store'); reply.header('Cache-Control', 'no-store');
return await reply.view('oauth', { return await HtmlTemplateService.replyHtml(reply, OAuthPage({
...await this.htmlTemplateService.getCommonData(),
transactionId: oauth2.transactionID, transactionId: oauth2.transactionID,
clientName: oauth2.client.name, clientName: oauth2.client.name,
clientLogo: oauth2.client.logo, clientLogo: oauth2.client.logo ?? undefined,
scope: oauth2.req.scope.join(' '), scope: oauth2.req.scope,
}); }));
}); });
fastify.post('/decision', async () => { }); fastify.post('/decision', async () => { });
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
await fastify.register(fastifyExpress); await fastify.register(fastifyExpress);
fastify.use('/authorize', this.#server.authorize(((areq, done) => { fastify.use('/authorize', this.#server.authorize(((areq, done) => {
(async (): Promise<Parameters<typeof done>> => { (async (): Promise<Parameters<typeof done>> => {

View File

@ -9,21 +9,16 @@ import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
import sharp from 'sharp'; import sharp from 'sharp';
import pug from 'pug';
import { In, IsNull } from 'typeorm'; import { In, IsNull } from 'typeorm';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
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 { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { MetaEntityService } from '@/core/entities/MetaEntityService.js';
import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
@ -42,14 +37,33 @@ import type {
} from '@/models/_.js'; } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { FeedService } from './FeedService.js'; import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js'; import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js'; import { ClientLoggerService } from './ClientLoggerService.js';
import { HtmlTemplateService } from './HtmlTemplateService.js';
import { BasePage } from './views/base.js';
import { UserPage } from './views/user.js';
import { NotePage } from './views/note.js';
import { PagePage } from './views/page.js';
import { ClipPage } from './views/clip.js';
import { FlashPage } from './views/flash.js';
import { GalleryPostPage } from './views/gallery-post.js';
import { ChannelPage } from './views/channel.js';
import { ReversiGamePage } from './views/reversi-game.js';
import { AnnouncementPage } from './views/announcement.js';
import { BaseEmbed } from './views/base-embed.js';
import { InfoCardPage } from './views/info-card.js';
import { BiosPage } from './views/bios.js';
import { CliPage } from './views/cli.js';
import { FlushPage } from './views/flush.js';
import { ErrorPage } from './views/error.js';
import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
@ -108,7 +122,6 @@ export class ClientServerService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private pageEntityService: PageEntityService, private pageEntityService: PageEntityService,
private metaEntityService: MetaEntityService,
private galleryPostEntityService: GalleryPostEntityService, private galleryPostEntityService: GalleryPostEntityService,
private clipEntityService: ClipEntityService, private clipEntityService: ClipEntityService,
private channelEntityService: ChannelEntityService, private channelEntityService: ChannelEntityService,
@ -116,7 +129,7 @@ export class ClientServerService {
private announcementEntityService: AnnouncementEntityService, private announcementEntityService: AnnouncementEntityService,
private urlPreviewService: UrlPreviewService, private urlPreviewService: UrlPreviewService,
private feedService: FeedService, private feedService: FeedService,
private roleService: RoleService, private htmlTemplateService: HtmlTemplateService,
private clientLoggerService: ClientLoggerService, private clientLoggerService: ClientLoggerService,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
@ -182,38 +195,10 @@ export class ClientServerService {
return (manifest); return (manifest);
} }
@bindThis
private async generateCommonPugData(meta: MiMeta) {
return {
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
appleTouchIcon: meta.app512IconUrl,
themeColor: meta.themeColor,
serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg',
infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',
notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg',
instanceUrl: this.config.url,
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)),
now: Date.now(),
federationEnabled: this.meta.federation !== 'none',
};
}
@bindThis @bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
const configUrl = new URL(this.config.url); const configUrl = new URL(this.config.url);
fastify.register(fastifyView, {
root: _dirname + '/views',
engine: {
pug: pug,
},
defaultContext: {
version: this.config.version,
config: this.config,
},
});
fastify.addHook('onRequest', (request, reply, done) => { fastify.addHook('onRequest', (request, reply, done) => {
// クリックジャッキング防止のためiFrameの中に入れられないようにする // クリックジャッキング防止のためiFrameの中に入れられないようにする
reply.header('X-Frame-Options', 'DENY'); reply.header('X-Frame-Options', 'DENY');
@ -414,16 +399,15 @@ export class ClientServerService {
//#endregion //#endregion
const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => { const renderBase = async (reply: FastifyReply, data: Partial<Parameters<typeof BasePage>[0]> = {}) => {
reply.header('Cache-Control', 'public, max-age=30'); reply.header('Cache-Control', 'public, max-age=30');
return await reply.view('base', { return await HtmlTemplateService.replyHtml(reply, BasePage({
img: this.meta.bannerUrl, img: this.meta.bannerUrl ?? undefined,
url: this.config.url,
title: this.meta.name ?? 'Misskey', title: this.meta.name ?? 'Misskey',
desc: this.meta.description, desc: this.meta.description ?? undefined,
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
...data, ...data,
}); }));
}; };
// URL preview endpoint // URL preview endpoint
@ -505,11 +489,6 @@ export class ClientServerService {
) )
) { ) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const me = profile.fields
? profile.fields
.filter(filed => filed.value != null && filed.value.match(/^https?:/))
.map(field => field.value)
: [];
reply.header('Cache-Control', 'public, max-age=15'); reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLearning) { if (profile.preventAiLearning) {
@ -522,15 +501,15 @@ export class ClientServerService {
userProfile: profile, userProfile: profile,
}); });
return await reply.view('user', { return await HtmlTemplateService.replyHtml(reply, UserPage({
user, profile, me, user: _user,
avatarUrl: _user.avatarUrl, profile,
sub: request.params.sub, sub: request.params.sub,
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
clientCtx: htmlSafeJsonStringify({ clientCtxJson: htmlSafeJsonStringify({
user: _user, user: _user,
}), }),
}); }));
} else { } else {
// リモートユーザーなので // リモートユーザーなので
// モデレータがAPI経由で参照可能にするために404にはしない // モデレータがAPI経由で参照可能にするために404にはしない
@ -581,17 +560,14 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai'); reply.header('X-Robots-Tag', 'noai');
} }
return await reply.view('note', { return await HtmlTemplateService.replyHtml(reply, NotePage({
note: _note, note: _note,
profile, profile,
avatarUrl: _note.user.avatarUrl, ...await this.htmlTemplateService.getCommonData(),
// TODO: Let locale changeable by instance setting clientCtxJson: htmlSafeJsonStringify({
summary: getNoteSummary(_note),
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
note: _note, note: _note,
}), }),
}); }));
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -624,12 +600,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai'); reply.header('X-Robots-Tag', 'noai');
} }
return await reply.view('page', { return await HtmlTemplateService.replyHtml(reply, PagePage({
page: _page, page: _page,
profile, profile,
avatarUrl: _page.user.avatarUrl, ...await this.htmlTemplateService.getCommonData(),
...await this.generateCommonPugData(this.meta), }));
});
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -649,12 +624,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai'); reply.header('X-Robots-Tag', 'noai');
} }
return await reply.view('flash', { return await HtmlTemplateService.replyHtml(reply, FlashPage({
flash: _flash, flash: _flash,
profile, profile,
avatarUrl: _flash.user.avatarUrl, ...await this.htmlTemplateService.getCommonData(),
...await this.generateCommonPugData(this.meta), }));
});
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -674,15 +648,14 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai'); reply.header('X-Robots-Tag', 'noai');
} }
return await reply.view('clip', { return await HtmlTemplateService.replyHtml(reply, ClipPage({
clip: _clip, clip: _clip,
profile, profile,
avatarUrl: _clip.user.avatarUrl, ...await this.htmlTemplateService.getCommonData(),
...await this.generateCommonPugData(this.meta), clientCtxJson: htmlSafeJsonStringify({
clientCtx: htmlSafeJsonStringify({
clip: _clip, clip: _clip,
}), }),
}); }));
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -700,12 +673,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai'); reply.header('X-Robots-Tag', 'noai');
} }
return await reply.view('gallery-post', { return await HtmlTemplateService.replyHtml(reply, GalleryPostPage({
post: _post, galleryPost: _post,
profile, profile,
avatarUrl: _post.user.avatarUrl, ...await this.htmlTemplateService.getCommonData(),
...await this.generateCommonPugData(this.meta), }));
});
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -720,10 +692,10 @@ export class ClientServerService {
if (channel) { if (channel) {
const _channel = await this.channelEntityService.pack(channel); const _channel = await this.channelEntityService.pack(channel);
reply.header('Cache-Control', 'public, max-age=15'); reply.header('Cache-Control', 'public, max-age=15');
return await reply.view('channel', { return await HtmlTemplateService.replyHtml(reply, ChannelPage({
channel: _channel, channel: _channel,
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
}); }));
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -738,10 +710,10 @@ export class ClientServerService {
if (game) { if (game) {
const _game = await this.reversiGameEntityService.packDetail(game); const _game = await this.reversiGameEntityService.packDetail(game);
reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('reversi-game', { return await HtmlTemplateService.replyHtml(reply, ReversiGamePage({
game: _game, reversiGame: _game,
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
}); }));
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -757,10 +729,10 @@ export class ClientServerService {
if (announcement) { if (announcement) {
const _announcement = await this.announcementEntityService.pack(announcement); const _announcement = await this.announcementEntityService.pack(announcement);
reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('announcement', { return await HtmlTemplateService.replyHtml(reply, AnnouncementPage({
announcement: _announcement, announcement: _announcement,
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
}); }));
} else { } else {
return await renderBase(reply); return await renderBase(reply);
} }
@ -793,13 +765,13 @@ export class ClientServerService {
const _user = await this.userEntityService.pack(user); const _user = await this.userEntityService.pack(user);
reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', { return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey', title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
embedCtx: htmlSafeJsonStringify({ embedCtxJson: htmlSafeJsonStringify({
user: _user, user: _user,
}), }),
}); }));
}); });
fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => { fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => {
@ -819,13 +791,13 @@ export class ClientServerService {
const _note = await this.noteEntityService.pack(note, null, { detail: true }); const _note = await this.noteEntityService.pack(note, null, { detail: true });
reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', { return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey', title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
embedCtx: htmlSafeJsonStringify({ embedCtxJson: htmlSafeJsonStringify({
note: _note, note: _note,
}), }),
}); }));
}); });
fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => { fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => {
@ -840,48 +812,46 @@ export class ClientServerService {
const _clip = await this.clipEntityService.pack(clip); const _clip = await this.clipEntityService.pack(clip);
reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', { return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey', title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
embedCtx: htmlSafeJsonStringify({ embedCtxJson: htmlSafeJsonStringify({
clip: _clip, clip: _clip,
}), }),
}); }));
}); });
fastify.get('/embed/*', async (request, reply) => { fastify.get('/embed/*', async (request, reply) => {
reply.removeHeader('X-Frame-Options'); reply.removeHeader('X-Frame-Options');
reply.header('Cache-Control', 'public, max-age=3600'); reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('base-embed', { return await HtmlTemplateService.replyHtml(reply, BaseEmbed({
title: this.meta.name ?? 'Misskey', title: this.meta.name ?? 'Misskey',
...await this.generateCommonPugData(this.meta), ...await this.htmlTemplateService.getCommonData(),
}); }));
}); });
fastify.get('/_info_card_', async (request, reply) => { fastify.get('/_info_card_', async (request, reply) => {
reply.removeHeader('X-Frame-Options'); reply.removeHeader('X-Frame-Options');
return await reply.view('info-card', { return await HtmlTemplateService.replyHtml(reply, InfoCardPage({
version: this.config.version, version: this.config.version,
host: this.config.host, config: this.config,
meta: this.meta, meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), }));
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
});
}); });
//#endregion //#endregion
fastify.get('/bios', async (request, reply) => { fastify.get('/bios', async (request, reply) => {
return await reply.view('bios', { return await HtmlTemplateService.replyHtml(reply, BiosPage({
version: this.config.version, version: this.config.version,
}); }));
}); });
fastify.get('/cli', async (request, reply) => { fastify.get('/cli', async (request, reply) => {
return await reply.view('cli', { return await HtmlTemplateService.replyHtml(reply, CliPage({
version: this.config.version, version: this.config.version,
}); }));
}); });
const override = (source: string, target: string, depth = 0) => const override = (source: string, target: string, depth = 0) =>
@ -904,7 +874,7 @@ export class ClientServerService {
reply.header('Clear-Site-Data', '"*"'); reply.header('Clear-Site-Data', '"*"');
} }
reply.header('Set-Cookie', 'http-flush-failed=1; Path=/flush; Max-Age=60'); reply.header('Set-Cookie', 'http-flush-failed=1; Path=/flush; Max-Age=60');
return await reply.view('flush'); return await HtmlTemplateService.replyHtml(reply, FlushPage());
}); });
// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる // streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる
@ -930,10 +900,10 @@ export class ClientServerService {
}); });
reply.code(500); reply.code(500);
reply.header('Cache-Control', 'max-age=10, must-revalidate'); reply.header('Cache-Control', 'max-age=10, must-revalidate');
return await reply.view('error', { return await HtmlTemplateService.replyHtml(reply, ErrorPage({
code: error.code, code: error.code,
id: errId, id: errId,
}); }));
}); });
done(); done();

View File

@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promises as fsp } from 'node:fs';
import { languages } from 'i18n/const';
import { Injectable, Inject } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js';
import { MetaEntityService } from '@/core/entities/MetaEntityService.js';
import type { FastifyReply } from 'fastify';
import type { Config } from '@/config.js';
import type { MiMeta } from '@/models/Meta.js';
import type { CommonData } from './views/_.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const frontendVitePublic = `${_dirname}/../../../../frontend/public/`;
const frontendEmbedVitePublic = `${_dirname}/../../../../frontend-embed/public/`;
@Injectable()
export class HtmlTemplateService {
private frontendBootloadersFetched = false;
public frontendBootloaderJs: string | null = null;
public frontendBootloaderCss: string | null = null;
public frontendEmbedBootloaderJs: string | null = null;
public frontendEmbedBootloaderCss: string | null = null;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
private metaEntityService: MetaEntityService,
) {
}
@bindThis
private async prepareFrontendBootloaders() {
if (this.frontendBootloadersFetched) return;
this.frontendBootloadersFetched = true;
const [bootJs, bootCss, embedBootJs, embedBootCss] = await Promise.all([
fsp.readFile(`${frontendVitePublic}loader/boot.js`, 'utf-8').catch(() => null),
fsp.readFile(`${frontendVitePublic}loader/style.css`, 'utf-8').catch(() => null),
fsp.readFile(`${frontendEmbedVitePublic}loader/boot.js`, 'utf-8').catch(() => null),
fsp.readFile(`${frontendEmbedVitePublic}loader/style.css`, 'utf-8').catch(() => null),
]);
if (bootJs != null) {
this.frontendBootloaderJs = bootJs;
}
if (bootCss != null) {
this.frontendBootloaderCss = bootCss;
}
if (embedBootJs != null) {
this.frontendEmbedBootloaderJs = embedBootJs;
}
if (embedBootCss != null) {
this.frontendEmbedBootloaderCss = embedBootCss;
}
}
@bindThis
public async getCommonData(): Promise<CommonData> {
await this.prepareFrontendBootloaders();
return {
version: this.config.version,
config: this.config,
langs: [...languages],
instanceName: this.meta.name ?? 'Misskey',
icon: this.meta.iconUrl,
appleTouchIcon: this.meta.app512IconUrl,
themeColor: this.meta.themeColor,
serverErrorImageUrl: this.meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg',
infoImageUrl: this.meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',
notFoundImageUrl: this.meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg',
instanceUrl: this.config.url,
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(this.meta)),
now: Date.now(),
federationEnabled: this.meta.federation !== 'none',
frontendBootloaderJs: this.frontendBootloaderJs,
frontendBootloaderCss: this.frontendBootloaderCss,
frontendEmbedBootloaderJs: this.frontendEmbedBootloaderJs,
frontendEmbedBootloaderCss: this.frontendEmbedBootloaderCss,
};
}
public static async replyHtml(reply: FastifyReply, html: string | Promise<string>) {
reply.header('Content-Type', 'text/html; charset=utf-8');
const _html = await html;
return reply.send(_html);
}
}

View File

@ -4,8 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { summaly } from '@misskey-dev/summaly'; import type { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
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 { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -113,7 +112,7 @@ export class UrlPreviewService {
} }
} }
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> { private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const agent = this.config.proxy const agent = this.config.proxy
? { ? {
http: this.httpRequestService.httpAgent, http: this.httpRequestService.httpAgent,
@ -121,6 +120,8 @@ export class UrlPreviewService {
} }
: undefined; : undefined;
const { summaly } = await import('@misskey-dev/summaly');
return summaly(url, { return summaly(url, {
followRedirects: this.meta.urlPreviewAllowRedirect, followRedirects: this.meta.urlPreviewAllowRedirect,
lang: lang ?? 'ja-JP', lang: lang ?? 'ja-JP',

View File

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Config } from '@/config.js';
export const comment = `<!--
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
-->`;
export const defaultDescription = '✨🌎✨ A interplanetary communication platform ✨🚀✨';
export type MinimumCommonData = {
version: string;
config: Config;
};
export type CommonData = MinimumCommonData & {
langs: string[];
instanceName: string;
icon: string | null;
appleTouchIcon: string | null;
themeColor: string | null;
serverErrorImageUrl: string;
infoImageUrl: string;
notFoundImageUrl: string;
instanceUrl: string;
now: number;
federationEnabled: boolean;
frontendBootloaderJs: string | null;
frontendBootloaderCss: string | null;
frontendEmbedBootloaderJs: string | null;
frontendEmbedBootloaderCss: string | null;
metaJson?: string;
clientCtxJson?: string;
};
export type CommonPropsMinimum<T = Record<string, any>> = MinimumCommonData & T;
export type CommonProps<T = Record<string, any>> = CommonData & T;

View File

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function Splash(props: {
icon?: string | null;
}) {
return (
<div id="splash">
<img id="splashIcon" src={props.icon || '/static-assets/splash.png'} />
<div id="splashSpinner">
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
</div>
</div>
);
}

View File

@ -1,21 +0,0 @@
extends ./base
block vars
- const title = announcement.title;
- const description = announcement.text.length > 100 ? announcement.text.slice(0, 100) + '…' : announcement.text;
- const url = `${config.url}/announcements/${announcement.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content=description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= description)
meta(property='og:url' content= url)
if announcement.imageUrl
meta(property='og:image' content=announcement.imageUrl)
meta(property='twitter:card' content='summary_large_image')

View File

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Packed } from '@/misc/json-schema.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Layout } from '@/server/web/views/base.js';
export function AnnouncementPage(props: CommonProps<{
announcement: Packed<'Announcement'>;
}>) {
const description = props.announcement.text.length > 100 ? props.announcement.text.slice(0, 100) + '…' : props.announcement.text;
function ogBlock() {
return (
<>
<meta property="og:type" content="article" />
<meta property="og:title" content={props.announcement.title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={`${props.config.url}/announcements/${props.announcement.id}`} />
{props.announcement.imageUrl ? (
<>
<meta property="og:image" content={props.announcement.imageUrl} />
<meta property="twitter:card" content="summary_large_image" />
</>
) : null}
</>
);
}
return (
<Layout
{...props}
title={`${props.announcement.title} | ${props.instanceName}`}
desc={description}
ogSlot={ogBlock()}
>
</Layout>
);
}

View File

@ -1,71 +0,0 @@
block vars
block loadClientEntry
- const entry = config.frontendEmbedEntry;
doctype html
html(class='embed')
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(property='instance_url' content= instanceUrl)
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
if !config.frontendEmbedManifestExists
script(type="module" src="/embed_vite/@vite/client")
if Array.isArray(entry.css)
each href in entry.css
link(rel='stylesheet' href=`/embed_vite/${href}`)
title
block title
= title || 'Misskey'
block meta
meta(name='robots' content='noindex')
style
include ../style.embed.css
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_embedCtx' data-generated-at=now)
!= embedCtx
script
include ../boot.embed.js
body
noscript: p
| JavaScriptを有効にしてください
br
| Please turn on your JavaScript
div#splash
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View File

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { comment } from '@/server/web/views/_.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Splash } from '@/server/web/views/_splash.js';
import type { PropsWithChildren, Children } from '@kitajs/html';
export function BaseEmbed(props: PropsWithChildren<CommonProps<{
title?: string;
noindex?: boolean;
desc?: string;
img?: string;
serverErrorImageUrl?: string;
infoImageUrl?: string;
notFoundImageUrl?: string;
metaJson?: string;
embedCtxJson?: string;
titleSlot?: Children;
metaSlot?: Children;
}>>) {
const now = Date.now();
// 変数名をsafeで始めることでエラーをスキップ
const safeMetaJson = props.metaJson;
const safeEmbedCtxJson = props.embedCtxJson;
return (
<>
{'<!DOCTYPE html>'}
{comment}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<meta name="referer" content="origin" />
<meta name="theme-color" content={props.themeColor ?? '#86b300'} />
<meta name="theme-color-orig" content={props.themeColor ?? '#86b300'} />
<meta property="og:site_name" content={props.instanceName || 'Misskey'} />
<meta property="instance_url" content={props.instanceUrl} />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no" />
<link rel="icon" href={props.icon ?? '/favicon.ico'} />
<link rel="apple-touch-icon" href={props.appleTouchIcon ?? '/apple-touch-icon.png'} />
{!props.config.frontendEmbedManifestExists ? <script type="module" src="/embed_vite/@vite/client"></script> : null}
{props.config.frontendEmbedEntry.css != null ? props.config.frontendEmbedEntry.css.map((href) => (
<link rel="stylesheet" href={`/embed_vite/${href}`} />
)) : null}
{props.titleSlot ?? <title safe>{props.title || 'Misskey'}</title>}
{props.metaSlot}
<meta name="robots" content="noindex" />
{props.frontendEmbedBootloaderCss != null ? <style safe>{props.frontendEmbedBootloaderCss}</style> : <link rel="stylesheet" href="/embed_vite/loader/style.css" />}
<script>
const VERSION = '{props.version}';
const CLIENT_ENTRY = {JSON.stringify(props.config.frontendEmbedEntry.file)};
const LANGS = {JSON.stringify(props.langs)};
</script>
{safeMetaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now}>{safeMetaJson}</script> : null}
{safeEmbedCtxJson != null ? <script type="application/json" id="misskey_embedCtx" data-generated-at={now}>{safeEmbedCtxJson}</script> : null}
{props.frontendEmbedBootloaderJs != null ? <script>{props.frontendEmbedBootloaderJs}</script> : <script src="/embed_vite/loader/boot.js"></script>}
</head>
<body>
<noscript>
<p>
JavaScriptを有効にしてください<br />
Please turn on your JavaScript
</p>
</noscript>
<Splash icon={props.icon} />
{props.children}
</body>
</html>
</>
);
}

View File

@ -1,100 +0,0 @@
block vars
block loadClientEntry
- const entry = config.frontendEntry;
- const baseUrl = config.url;
doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(property='instance_url' content= instanceUrl)
meta(name='viewport' content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='manifest' href='/manifest.json')
link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${baseUrl}/opensearch.xml`)
link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
if Array.isArray(entry.css)
each href in entry.css
link(rel='stylesheet' href=`/vite/${href}`)
title
block title
= title || 'Misskey'
if noindex
meta(name='robots' content='noindex')
block desc
meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
block meta
block og
meta(property='og:title' content= title || 'Misskey')
meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
meta(property='og:image' content= img)
meta(property='twitter:card' content='summary')
style
include ../style.css
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_clientCtx' data-generated-at=now)
!= clientCtx
script
include ../boot.js
body
noscript: p
| JavaScriptを有効にしてください
br
| Please turn on your JavaScript
div#splash
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View File

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { comment, defaultDescription } from '@/server/web/views/_.js';
import { Splash } from '@/server/web/views/_splash.js';
import type { CommonProps } from '@/server/web/views/_.js';
import type { PropsWithChildren, Children } from '@kitajs/html';
export function Layout(props: PropsWithChildren<CommonProps<{
title?: string;
noindex?: boolean;
desc?: string;
img?: string;
serverErrorImageUrl?: string;
infoImageUrl?: string;
notFoundImageUrl?: string;
metaJson?: string;
clientCtxJson?: string;
titleSlot?: Children;
descSlot?: Children;
metaSlot?: Children;
ogSlot?: Children;
}>>) {
const now = Date.now();
// 変数名をsafeで始めることでエラーをスキップ
const safeMetaJson = props.metaJson;
const safeClientCtxJson = props.clientCtxJson;
return (
<>
{'<!DOCTYPE html>'}
{comment}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<meta name="referer" content="origin" />
<meta name="theme-color" content={props.themeColor ?? '#86b300'} />
<meta name="theme-color-orig" content={props.themeColor ?? '#86b300'} />
<meta property="og:site_name" content={props.instanceName || 'Misskey'} />
<meta property="instance_url" content={props.instanceUrl} />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no" />
<link rel="icon" href={props.icon || '/favicon.ico'} />
<link rel="apple-touch-icon" href={props.appleTouchIcon || '/apple-touch-icon.png'} />
<link rel="manifest" href="/manifest.json" />
<link rel="search" type="application/opensearchdescription+xml" title={props.title || 'Misskey'} href={`${props.config.url}/opensearch.xml`} />
{props.serverErrorImageUrl != null ? <link rel="prefetch" as="image" href={props.serverErrorImageUrl} /> : null}
{props.infoImageUrl != null ? <link rel="prefetch" as="image" href={props.infoImageUrl} /> : null}
{props.notFoundImageUrl != null ? <link rel="prefetch" as="image" href={props.notFoundImageUrl} /> : null}
{!props.config.frontendManifestExists ? <script type="module" src="/vite/@vite/client"></script> : null}
{props.config.frontendEntry.css != null ? props.config.frontendEntry.css.map((href) => (
<link rel="stylesheet" href={`/vite/${href}`} />
)) : null}
{props.titleSlot ?? <title safe>{props.title || 'Misskey'}</title>}
{props.noindex ? <meta name="robots" content="noindex" /> : null}
{props.descSlot ?? (props.desc != null ? <meta name="description" content={props.desc || defaultDescription} /> : null)}
{props.metaSlot}
{props.ogSlot ?? (
<>
<meta property="og:title" content={props.title || 'Misskey'} />
<meta property="og:description" content={props.desc || defaultDescription} />
{props.img != null ? <meta property="og:image" content={props.img} /> : null}
<meta property="twitter:card" content="summary" />
</>
)}
{props.frontendBootloaderCss != null ? <style safe>{props.frontendBootloaderCss}</style> : <link rel="stylesheet" href="/vite/loader/style.css" />}
<script>
const VERSION = '{props.version}';
const CLIENT_ENTRY = {JSON.stringify(props.config.frontendEntry.file)};
const LANGS = {JSON.stringify(props.langs)};
</script>
{safeMetaJson != null ? <script type="application/json" id="misskey_meta" data-generated-at={now}>{safeMetaJson}</script> : null}
{safeClientCtxJson != null ? <script type="application/json" id="misskey_clientCtx" data-generated-at={now}>{safeClientCtxJson}</script> : null}
{props.frontendBootloaderJs != null ? <script>{props.frontendBootloaderJs}</script> : <script src="/vite/loader/boot.js"></script>}
</head>
<body>
<noscript>
<p>
JavaScriptを有効にしてください<br />
Please turn on your JavaScript
</p>
</noscript>
<Splash icon={props.icon} />
{props.children}
</body>
</html>
</>
);
}
export { Layout as BasePage };

View File

@ -1,20 +0,0 @@
doctype html
html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
title Misskey Repair Tool
style
include ../bios.css
script
include ../bios.js
body
header
h1 Misskey Repair Tool #{version}
main
div.tabs
button#ls edit local storage
div#content

View File

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function BiosPage(props: {
version: string;
}) {
return (
<>
{'<!DOCTYPE html>'}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<title>Misskey Repair Tool</title>
<link rel="stylesheet" href="/static-assets/misc/bios.css" />
</head>
<body>
<header>
<h1 safe>Misskey Repair Tool {props.version}</h1>
</header>
<main>
<div class="tabs">
<button id="ls">edit local storage</button>
</div>
<div id="content"></div>
</main>
<script src="/static-assets/misc/bios.js"></script>
</body>
</html>
</>
);
}

View File

@ -1,19 +0,0 @@
extends ./base
block vars
- const title = channel.name;
- const url = `${config.url}/channels/${channel.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= channel.description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= channel.description)
meta(property='og:url' content= url)
meta(property='og:image' content= channel.bannerUrl)
meta(property='twitter:card' content='summary')

View File

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Packed } from '@/misc/json-schema.js';
import type { CommonProps } from '@/server/web/views/_.js';
import { Layout } from '@/server/web/views/base.js';
export function ChannelPage(props: CommonProps<{
channel: Packed<'Channel'>;
}>) {
function ogBlock() {
return (
<>
<meta property="og:type" content="website" />
<meta property="og:title" content={props.channel.name} />
{props.channel.description != null ? <meta property="og:description" content={props.channel.description} /> : null}
<meta property="og:url" content={`${props.config.url}/channels/${props.channel.id}`} />
{props.channel.bannerUrl ? (
<>
<meta property="og:image" content={props.channel.bannerUrl} />
<meta property="twitter:card" content="summary" />
</>
) : null}
</>
);
}
return (
<Layout
{...props}
title={`${props.channel.name} | ${props.instanceName}`}
desc={props.channel.description ?? undefined}
ogSlot={ogBlock()}
>
</Layout>
);
}

View File

@ -1,21 +0,0 @@
doctype html
html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
title Misskey Cli
style
include ../cli.css
script
include ../cli.js
body
header
h1 Misskey Cli #{version}
main
div#form
textarea#text
button#submit submit
div#tl

View File

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function CliPage(props: {
version: string;
}) {
return (
<>
{'<!DOCTYPE html>'}
<html>
<head>
<meta charset="UTF-8" />
<meta name="application-name" content="Misskey" />
<title>Misskey CLI Tool</title>
<link rel="stylesheet" href="/static-assets/misc/cli.css" />
</head>
<body>
<header>
<h1 safe>Misskey CLI {props.version}</h1>
</header>
<main>
<div id="form">
<textarea id="text"></textarea>
<button id="submit">Submit</button>
</div>
<div id="tl"></div>
</main>
<script src="/static-assets/misc/cli.js"></script>
</body>
</html>
</>
);
}

View File

@ -1,35 +0,0 @@
extends ./base
block vars
- const user = clip.user;
- const title = clip.name;
- const url = `${config.url}/clips/${clip.id}`;
block title
= `${title} | ${instanceName}`
block desc
meta(name='description' content= clip.description)
block og
meta(property='og:type' content='article')
meta(property='og:title' content= title)
meta(property='og:description' content= clip.description)
meta(property='og:url' content= url)
meta(property='og:image' content= avatarUrl)
meta(property='twitter:card' content='summary')
block meta
if profile.noCrawle
meta(name='robots' content='noindex')
if profile.preventAiLearning
meta(name='robots' content='noimageai')
meta(name='robots' content='noai')
meta(name='misskey:user-username' content=user.username)
meta(name='misskey:user-id' content=user.id)
meta(name='misskey:clip-id' content=clip.id)
// todo
if user.twitter
meta(name='twitter:creator' content=`@${user.twitter.screenName}`)

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