Compare commits
110 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ceab34f5f3 | ||
![]() |
3a62625bbc | ||
![]() |
ad6844ac4a | ||
![]() |
a8c252a613 | ||
![]() |
1d39f785f1 | ||
![]() |
4b8b29b862 | ||
![]() |
d195406fdc | ||
![]() |
5173ed37f9 | ||
![]() |
825551d64f | ||
![]() |
449761bada | ||
![]() |
5859df389f | ||
![]() |
562b02310f | ||
![]() |
65ed702d87 | ||
![]() |
c559a9843f | ||
![]() |
88c3957085 | ||
![]() |
01778e11dc | ||
![]() |
9d9e8a3c4e | ||
![]() |
ed3e035ad6 | ||
![]() |
07f885fea8 | ||
![]() |
2cc98226ca | ||
![]() |
8a6f73c5ff | ||
![]() |
00e3453ce1 | ||
![]() |
16646dd77a | ||
![]() |
1f39d1fe26 | ||
![]() |
e8f3c587c9 | ||
![]() |
4b43745e7c | ||
![]() |
9db2f60053 | ||
![]() |
4610d8dfe3 | ||
![]() |
fa296efdf6 | ||
![]() |
d9d98f84bf | ||
![]() |
7c3143b8e5 | ||
![]() |
387fcd5c5d | ||
![]() |
ebc6437977 | ||
![]() |
dbc23b5d20 | ||
![]() |
843f1aed4f | ||
![]() |
e42938cad6 | ||
![]() |
2a41f6c383 | ||
![]() |
671d21a2c1 | ||
![]() |
515692d7a6 | ||
![]() |
00d28826b9 | ||
![]() |
5b38f76254 | ||
![]() |
ca7dbd6010 | ||
![]() |
133644e5a9 | ||
![]() |
04d60426c7 | ||
![]() |
8282bbd07c | ||
![]() |
7190bd00c9 | ||
![]() |
44b9539818 | ||
![]() |
b2ed4c9508 | ||
![]() |
c7b5c8b19e | ||
![]() |
f4bee24ccf | ||
![]() |
e9cb18c5aa | ||
![]() |
d8f33bc0af | ||
![]() |
663999556f | ||
![]() |
c5a12ca2c7 | ||
![]() |
7af0e38dd3 | ||
![]() |
7d9d1ae7c2 | ||
![]() |
cef448f0f2 | ||
![]() |
67d64c9365 | ||
![]() |
269af9d6b9 | ||
![]() |
d37a734379 | ||
![]() |
7cb13cf839 | ||
![]() |
d7dda8f6e3 | ||
![]() |
6670c72f8b | ||
![]() |
b21064ffa4 | ||
![]() |
1959cb462b | ||
![]() |
1d6767ef0c | ||
![]() |
4735ae6451 | ||
![]() |
452bd6db25 | ||
![]() |
f5d6b84381 | ||
![]() |
34f5d81d1f | ||
![]() |
aa8adc07aa | ||
![]() |
d87bb807c3 | ||
![]() |
7646d6ed47 | ||
![]() |
41a6ed0de0 | ||
![]() |
ec8074cd49 | ||
![]() |
7131eb1827 | ||
![]() |
605b0f27e4 | ||
![]() |
80d2e157f6 | ||
![]() |
1e3447bccb | ||
![]() |
5ffa106cc1 | ||
![]() |
fc641c9b96 | ||
![]() |
5f49ac1b11 | ||
![]() |
9ffecf25dc | ||
![]() |
35fd523edf | ||
![]() |
6721d4216c | ||
![]() |
e3275e916b | ||
![]() |
3ba5541a66 | ||
![]() |
945c50db1f | ||
![]() |
30dce42e03 | ||
![]() |
d4fb201d05 | ||
![]() |
2a2e8d0cf6 | ||
![]() |
520ed8cb4d | ||
![]() |
8cab16c824 | ||
![]() |
26fbb3a560 | ||
![]() |
93dd0638ad | ||
![]() |
0d44129ae3 | ||
![]() |
0cffe60abc | ||
![]() |
8a6750278e | ||
![]() |
d347f0a087 | ||
![]() |
226e0c4714 | ||
![]() |
0b2f945bb6 | ||
![]() |
2f6c45e118 | ||
![]() |
a5f54580a9 | ||
![]() |
a8b19f4aa8 | ||
![]() |
890564e1da | ||
![]() |
002f98987d | ||
![]() |
43956f3ffb | ||
![]() |
f2a9194c79 | ||
![]() |
4cd70df7f4 | ||
![]() |
21e4c3dfe9 |
@@ -114,11 +114,6 @@ id: 'aid'
|
||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Syslog option
|
||||
#syslog:
|
||||
# host: localhost
|
||||
# port: 514
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
|
@@ -114,11 +114,6 @@ id: 'aid'
|
||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Syslog option
|
||||
#syslog:
|
||||
# host: localhost
|
||||
# port: 514
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
|
2
.github/workflows/docker-develop.yml
vendored
2
.github/workflows/docker-develop.yml
vendored
@@ -31,3 +31,5 @@ jobs:
|
||||
push: true
|
||||
tags: misskey/misskey:develop
|
||||
labels: develop
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -109,8 +109,12 @@ jobs:
|
||||
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
|
||||
- name: ALSA Env
|
||||
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
|
||||
# XXX: This tries reinstalling Cypress if the binary is not cached
|
||||
# Remove this when the cache issue is fixed
|
||||
- name: Cypress install
|
||||
run: pnpm exec cypress install
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v4
|
||||
uses: cypress-io/github-action@v5
|
||||
with:
|
||||
install: false
|
||||
start: pnpm start:test
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ coverage
|
||||
!/.config/example.yml
|
||||
!/.config/docker_example.yml
|
||||
!/.config/docker_example.env
|
||||
docker-compose.yml
|
||||
|
||||
# misskey
|
||||
/build
|
||||
|
@@ -1 +1 @@
|
||||
v18.12.1
|
||||
v18.13.0
|
||||
|
50
CHANGELOG.md
50
CHANGELOG.md
@@ -9,6 +9,56 @@
|
||||
You should also include the user name that made the change.
|
||||
-->
|
||||
|
||||
## 13.3.0 (2023/02/03)
|
||||
### Changes
|
||||
- twitter/github/discord連携機能が削除されました
|
||||
- ハッシュタグごとのチャートが削除されました
|
||||
- syslogのサポートが削除されました
|
||||
|
||||
### Improvements
|
||||
- ロールで広告の非表示が有効になっている場合は最初から広告を非表示にするように
|
||||
|
||||
## 13.2.6 (2023/02/01)
|
||||
### Changes
|
||||
- docker-compose.ymlをdocker-compose.yml.exampleにしました。docker-compose.ymlとしてコピーしてから使用してください。
|
||||
|
||||
### Improvements
|
||||
- 絵文字ピッカーのパフォーマンスを改善
|
||||
- AiScriptを0.12.4に更新
|
||||
|
||||
### Bugfixes
|
||||
- Server: リレーと通信できない問題を修正
|
||||
- Client: classicモード使用時にwindowサイズによってdefaultに変更された後に、windowサイズが元に戻ったらclassicに戻すように修正 #9669
|
||||
- Client: Chromeで検索ダイアログで変換確定するとそのまま検索されてしまう問題を修正
|
||||
|
||||
## 13.2.4 (2023/01/27)
|
||||
### Improvements
|
||||
- リモートカスタム絵文字表示時のパフォーマンスを改善
|
||||
- Default to `animation: false` when prefers-reduced-motion is set
|
||||
- リアクション履歴が公開なら、ログインしていなくても表示できるように
|
||||
- tweak blur setting
|
||||
- tweak custom emoji cache
|
||||
|
||||
### Bugfixes
|
||||
- fix aggregation of retention
|
||||
- ダッシュボードでオンラインユーザー数が表示されない問題を修正
|
||||
- フォロー申請・フォローのボタンが、通知から消えている問題を修正
|
||||
|
||||
## 13.2.3 (2023/01/26)
|
||||
### Improvements
|
||||
- カスタム絵文字の更新をリアルタイムで反映するように
|
||||
|
||||
### Bugfixes
|
||||
- turnstile-failed: missing-input-secret
|
||||
|
||||
## 13.2.2 (2023/01/25)
|
||||
### Improvements
|
||||
- サーバーのパフォーマンスを改善
|
||||
|
||||
### Bugfixes
|
||||
- サインイン時に誤ったレートリミットがかかることがある問題を修正
|
||||
- MFMのposition、rotate、scaleで小数が使えない問題を修正
|
||||
|
||||
## 13.2.1 (2023/01/24)
|
||||
### Improvements
|
||||
- デザインの調整
|
||||
|
@@ -44,7 +44,7 @@ Thank you for your PR! Before creating a PR, please check the following:
|
||||
- Check if there are any documents that need to be created or updated due to this change.
|
||||
- If you have added a feature or fixed a bug, please add a test case if possible.
|
||||
- Please make sure that tests and Lint are passed in advance.
|
||||
- You can run it with `yarn test` and `yarn lint`. [See more info](#testing)
|
||||
- You can run it with `pnpm test` and `pnpm lint`. [See more info](#testing)
|
||||
- If this PR includes UI changes, please attach a screenshot in the text.
|
||||
|
||||
Thanks for your cooperation 🤗
|
||||
@@ -102,7 +102,7 @@ If your language is not listed in Crowdin, please open an issue.
|
||||
During development, it is useful to use the
|
||||
|
||||
```
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
command.
|
||||
@@ -112,7 +112,7 @@ command.
|
||||
- Service Worker is watched by esbuild.
|
||||
|
||||
## Testing
|
||||
- Test codes are located in [`/test`](/test).
|
||||
- Test codes are located in [`/packages/backend/test`](/packages/backend/test).
|
||||
|
||||
### Run test
|
||||
Create a config file.
|
||||
@@ -127,12 +127,12 @@ Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.y
|
||||
|
||||
Run all test.
|
||||
```
|
||||
yarn test
|
||||
pnpm test
|
||||
```
|
||||
|
||||
#### Run specify test
|
||||
```
|
||||
yarn jest -- foo.ts
|
||||
pnpm jest -- foo.ts
|
||||
```
|
||||
|
||||
### e2e tests
|
||||
@@ -177,9 +177,9 @@ vue-routerとの最大の違いは、niraxは複数のルーターが存在す
|
||||
これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。
|
||||
|
||||
## Notes
|
||||
### How to resolve conflictions occurred at yarn.lock?
|
||||
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
||||
|
||||
Just execute `yarn` to fix it.
|
||||
Just execute `pnpm` to fix it.
|
||||
|
||||
### INSERTするときにはsaveではなくinsertを使用する
|
||||
#6441
|
||||
@@ -265,7 +265,7 @@ MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`
|
||||
### Migration作成方法
|
||||
packages/backendで:
|
||||
```sh
|
||||
yarn dlx typeorm migration:generate -d ormconfig.js -o <migration name>
|
||||
pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name>
|
||||
```
|
||||
|
||||
- 生成後、ファイルをmigration下に移してください
|
||||
|
19
Dockerfile
19
Dockerfile
@@ -2,8 +2,12 @@ ARG NODE_VERSION=18.13.0-bullseye
|
||||
|
||||
FROM node:${NODE_VERSION} AS builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \
|
||||
&& apt-get update \
|
||||
&& apt-get install -yqq --no-install-recommends \
|
||||
build-essential
|
||||
|
||||
RUN corepack enable
|
||||
@@ -16,7 +20,8 @@ COPY ["packages/backend/package.json", "./packages/backend/"]
|
||||
COPY ["packages/frontend/package.json", "./packages/frontend/"]
|
||||
COPY ["packages/sw/package.json", "./packages/sw/"]
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm i --frozen-lockfile --aggregate-output
|
||||
|
||||
COPY . ./
|
||||
|
||||
@@ -30,11 +35,13 @@ FROM node:${NODE_VERSION}-slim AS runner
|
||||
ARG UID="991"
|
||||
ARG GID="991"
|
||||
|
||||
RUN apt-get update \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg tini \
|
||||
&& apt-get -y clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable \
|
||||
&& groupadd -g "${GID}" misskey \
|
||||
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey
|
||||
|
@@ -24,6 +24,8 @@
|
||||
|
||||
---
|
||||
|
||||
[](https://codecov.io/gh/misskey-dev/misskey)
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
@@ -133,11 +133,6 @@ id: "aid"
|
||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Syslog option
|
||||
#syslog:
|
||||
# host: localhost
|
||||
# port: 514
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
|
@@ -509,7 +509,7 @@ objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir "
|
||||
serverLogs: "Registros del servidor"
|
||||
deleteAll: "Eliminar todos"
|
||||
showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo"
|
||||
newNoteRecived: "Tienes una nota nuevo"
|
||||
newNoteRecived: "Tienes una nota nueva"
|
||||
sounds: "Sonidos"
|
||||
sound: "Sonidos"
|
||||
listen: "Escuchar"
|
||||
@@ -918,14 +918,320 @@ tools: "Utilidades"
|
||||
cannotLoad: "No se puede cargar."
|
||||
numberOfProfileView: "Número de vistas de perfil"
|
||||
like: "¡Muy bien!"
|
||||
unlike: "Quitar 'me gusta'"
|
||||
numberOfLikes: "Cantidad de 'Me gusta'"
|
||||
show: "Apariencia"
|
||||
neverShow: "No mostrar de nuevo"
|
||||
remindMeLater: "Recordar después"
|
||||
didYouLikeMisskey: "¿Te gusta Misskey?"
|
||||
pleaseDonate: "Misskey es software libre, y es usado por {host} . Por favor, ¡considera donar al proyecto principal para que podamos continuar!"
|
||||
roles: "Roles"
|
||||
role: "Roles"
|
||||
normalUser: "Usuario normal"
|
||||
undefined: "Indefinido"
|
||||
assign: "Asignar"
|
||||
unassign: "Quitar"
|
||||
color: "Color"
|
||||
manageCustomEmojis: "Administrar emojis personalizados"
|
||||
youCannotCreateAnymore: "Se alcanzó el límite de creación"
|
||||
cannotPerformTemporary: "Indisponible temporalmente"
|
||||
cannotPerformTemporaryDescription: "Esta acción no se puede realizar porque se excedió el límite de ejecución. Espera un poco y prueba de nuevo."
|
||||
preset: "Predefinido"
|
||||
selectFromPresets: "Escoger desde predefinidos"
|
||||
achievements: "Logros"
|
||||
_achievements:
|
||||
earnedAt: "Desbloqueado el"
|
||||
_types:
|
||||
_notes1:
|
||||
title: "Configurando mis espacio"
|
||||
description: "Publicar tu primera nota"
|
||||
flavor: "¡Pasándola bien con Misskey!"
|
||||
_notes10:
|
||||
title: "Algunas notas"
|
||||
description: "10 notas publicadas"
|
||||
_notes100:
|
||||
title: "¡Muchas notas!"
|
||||
description: "100 notas publicadas"
|
||||
_notes500:
|
||||
title: "¡Cubierto de notas!"
|
||||
description: "500 notas publicadas"
|
||||
_notes1000:
|
||||
title: "¡Una montaña de notas!"
|
||||
description: "1000 notas publicadas"
|
||||
_notes5000:
|
||||
title: "¡Exceso de notas!"
|
||||
description: "5000 notas publicadas"
|
||||
_notes10000:
|
||||
title: "¡Súpernota!"
|
||||
description: "10000 notas publicadas"
|
||||
_notes20000:
|
||||
title: "Necesito... Más... ¡Notas!"
|
||||
description: "20000 notas publicadas"
|
||||
_notes30000:
|
||||
title: "¡Notas! ¡Notas! ¡Notas!"
|
||||
description: "30000 notas publicadas"
|
||||
_notes40000:
|
||||
title: "Fábrica de notas"
|
||||
description: "40000 notas publicadas"
|
||||
_notes50000:
|
||||
title: "¡Un planeta de notas!"
|
||||
description: "50000 notas publicadas"
|
||||
_notes60000:
|
||||
title: "¡Un cuásar de notas!"
|
||||
description: "60000 notas publicadas"
|
||||
_notes70000:
|
||||
title: "¡Un hoyo negro de notas!"
|
||||
description: "70000 notas publicadas"
|
||||
_notes80000:
|
||||
title: "¡Una galaxia de notas!"
|
||||
description: "80000 notas publicadas"
|
||||
_notes90000:
|
||||
title: "¡Todo un universo de notas!"
|
||||
description: "90000 notas publicadas"
|
||||
_notes100000:
|
||||
title: "ALL YOUR NOTE ARE BELONG TO US"
|
||||
description: "100000 notas publicadas"
|
||||
flavor: "¿Tienes tanto para publicar?"
|
||||
_login3:
|
||||
title: "Principiante I"
|
||||
description: "Días desde el inicio de sesión: 3"
|
||||
flavor: "Desde hoy, soy Misskero"
|
||||
_login7:
|
||||
title: "Principiante II"
|
||||
description: "Días desde el inicio de sesión: 7"
|
||||
flavor: "¿Ya te acostumbraste?"
|
||||
_login15:
|
||||
title: "Principiante III"
|
||||
description: "Días desde el inicio de sesión: 15"
|
||||
_login30:
|
||||
title: "Misskero I"
|
||||
description: "Días desde el inicio de sesión: 30"
|
||||
_login60:
|
||||
title: "Misskero II"
|
||||
description: "Días desde el inicio de sesión: 60"
|
||||
_login100:
|
||||
title: "Misskero III"
|
||||
description: "Días desde el inicio de sesión: 100"
|
||||
flavor: "Para este usuario, Misskaína"
|
||||
_login200:
|
||||
title: "Regular I"
|
||||
description: "Días desde el inicio de sesión: 200"
|
||||
_login300:
|
||||
title: "Regular II"
|
||||
description: "Días desde el inicio de sesión: 300"
|
||||
_login400:
|
||||
title: "Regular III"
|
||||
description: "Días desde el inicio de sesión: 400"
|
||||
_login500:
|
||||
title: "Veterano I"
|
||||
description: "Días desde el inicio de sesión: 500"
|
||||
flavor: "Chicos, me encantan las libretas..."
|
||||
_login600:
|
||||
title: "Veterano II"
|
||||
description: "Días desde el inicio de sesión: 600"
|
||||
_login700:
|
||||
title: "Veterano III"
|
||||
description: "Días desde el inicio de sesión: 700"
|
||||
_login800:
|
||||
title: "Maestro I"
|
||||
description: "Días desde el inicio de sesión: 800"
|
||||
_login900:
|
||||
title: "Maestro II"
|
||||
description: "Días desde el inicio de sesión: 900"
|
||||
_login1000:
|
||||
title: "Maestro III"
|
||||
description: "Días desde el inicio de sesión: 1000"
|
||||
flavor: "¡Gracias por usar Misskey!"
|
||||
_noteClipped1:
|
||||
title: "No puedo evitar clipearte..."
|
||||
description: "Hacer un clip por primera vez"
|
||||
_noteFavorited1:
|
||||
title: "Contemplando las estrellas"
|
||||
description: "Poner una nota como favorito por primera vez"
|
||||
_myNoteFavorited1:
|
||||
title: "¡Quiero una estrella!"
|
||||
description: "Tu nota ha sido marcada como favorito por primera vez"
|
||||
_profileFilled:
|
||||
title: "¡Listo!"
|
||||
description: "Perfil completado"
|
||||
_markedAsCat:
|
||||
title: "Soy un gato"
|
||||
description: "Configurar la cuenta como cuenta de un gato"
|
||||
flavor: "Aún no tengo nombre"
|
||||
_following1:
|
||||
title: "Primera vez siguiendo a alguien"
|
||||
description: "Seguir a un usuario"
|
||||
_following10:
|
||||
title: "Ahí la llevas, ahí la llevas..."
|
||||
description: "10 usuarios seguidos"
|
||||
_following50:
|
||||
title: "¡Un puñado de amigos!"
|
||||
description: "50 cuentas seguidas"
|
||||
_following100:
|
||||
title: "100 amigos"
|
||||
description: "100 cuentas seguidas"
|
||||
_following300:
|
||||
title: "¡Sobrecarga de amigos!"
|
||||
description: "300 cuentas seguidas"
|
||||
_followers1:
|
||||
title: "¡Tu primer seguidor!"
|
||||
description: "1 seguidor ganado"
|
||||
_followers10:
|
||||
title: "¡Sígueme!"
|
||||
description: "10 seguidores ganados"
|
||||
_followers50:
|
||||
title: "Viniendo en manada"
|
||||
description: "50 seguidores ganados"
|
||||
_followers100:
|
||||
title: "Popular"
|
||||
description: "100 cuentas seguidas"
|
||||
_followers300:
|
||||
title: "Por favor, hagan una fila"
|
||||
description: "300 seguidores ganados"
|
||||
_followers500:
|
||||
title: "¡Toda una torre de radio!"
|
||||
description: "500 seguidores ganados"
|
||||
_followers1000:
|
||||
title: "\"Influyente\""
|
||||
description: "1000 seguidores gandos"
|
||||
_collectAchievements30:
|
||||
title: "Coleccionista"
|
||||
description: "30 logros ganados"
|
||||
_viewAchievements3min:
|
||||
title: "¡Te gustan los logros!"
|
||||
description: "Mirando tus logros por 3 minutos"
|
||||
_iLoveMisskey:
|
||||
title: "¡AMO Misskey!"
|
||||
description: "\"I ❤ #Misskey\" Publicado"
|
||||
flavor: "El equipo de desarrollo de Misskey, en verdad, ¡aprecia tu apoyo!"
|
||||
_foundTreasure:
|
||||
title: "Búsqueda del tesoro"
|
||||
description: "Encontraste un tesoro"
|
||||
_client30min:
|
||||
title: "Un descansito"
|
||||
description: "30 minutos dedicados a Misskey"
|
||||
_noteDeletedWithin1min:
|
||||
title: "Ah... Mejor no..."
|
||||
description: "Borrar una nota antes que de pase 1 minuto"
|
||||
_postedAtLateNight:
|
||||
title: "Nocturno"
|
||||
description: "Una nota publicada por la noche"
|
||||
flavor: "¡Ya casi es hora de dormir!"
|
||||
_postedAt0min0sec:
|
||||
title: "Reloj parlante"
|
||||
description: "Publicar una nota a las 00:00 de la madrugada"
|
||||
flavor: "Tic, tic, tic ¡TUUUUUN!"
|
||||
_selfQuote:
|
||||
title: "Autoreferencia"
|
||||
description: "Citar tu propia nota"
|
||||
_htl20npm:
|
||||
title: "Línea de tiempo fluyendo"
|
||||
description: "La velocidad de tu línea de tiempo excede las 20 npm (notas por minuto)"
|
||||
_viewInstanceChart:
|
||||
title: "Analista"
|
||||
description: "Gráficas de la instancia mostradas"
|
||||
_outputHelloWorldOnScratchpad:
|
||||
title: "¡Hola mundo!"
|
||||
description: "Escribir \"hello world\" en el compositor"
|
||||
_open3windows:
|
||||
title: "Multiventana"
|
||||
description: "Tener más de 3 ventanas al mismo tiempo"
|
||||
_driveFolderCircularReference:
|
||||
title: "Referencia circular"
|
||||
description: "Intento de crear carpetas recursivamente"
|
||||
_reactWithoutRead:
|
||||
title: "¡Sí lo leíste bien?"
|
||||
description: "Reaccionar a los 3 segundos de publicación de una nota con más de 100 caracteres"
|
||||
_clickedClickHere:
|
||||
title: "Pícale aquí"
|
||||
description: "Le picó ahí"
|
||||
_justPlainLucky:
|
||||
title: "Pura suerte"
|
||||
description: "Obtenido con una probabilidad del 0.01% cada 10 segundos"
|
||||
_setNameToSyuilo:
|
||||
title: "Complejo de superioridad"
|
||||
description: "Configurar el nombre como 'Syuilo'"
|
||||
_passedSinceAccountCreated1:
|
||||
title: "Primer aniversario"
|
||||
description: "Pasó un año desde la creación de la cuenta"
|
||||
_passedSinceAccountCreated2:
|
||||
title: "Segundo aniversario"
|
||||
description: "Pasaron dos años desde la creación de la cuenta"
|
||||
_passedSinceAccountCreated3:
|
||||
title: "Tercer aniversario"
|
||||
description: "Pasaron tres años desde la creación de la cuenta"
|
||||
_loggedInOnBirthday:
|
||||
title: "¡Feliz cumpleaños!"
|
||||
description: "En linea el día de tu cumpleaños"
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "¡Feliz Año Nuevo!"
|
||||
description: "En linea en año nuevo"
|
||||
flavor: "¡Gracias por tu apoyo a la instancia durante todo este año!"
|
||||
_cookieClicked:
|
||||
title: "Un juego para picarle a una galleta"
|
||||
description: "Picaste una galleta"
|
||||
flavor: "¿Está mal este juego?"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
description: "Publicaste un vínculo a \"Brain Diver\""
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
_role:
|
||||
new: "Crear rol"
|
||||
edit: "Editar rol"
|
||||
name: "Nombre del rol"
|
||||
description: "Descripción del rol"
|
||||
permission: "Permisos del rol"
|
||||
descriptionOfPermission: "<b>Moderador</b> Te permite ejecutar acciones básicas de moderación.\n<b>Administradores</b> puede cambiar todas las configuraciones de la instancia."
|
||||
assignTarget: "Asignar objetivo"
|
||||
descriptionOfAssignTarget: "<b>Manual</b> Para cambiar manualmente lo que se incluye en este rol.\n<b>Condicional</b> configura una condición, y los usuarios que cumplan la condición serán incluídos automáticamente."
|
||||
manual: "manual"
|
||||
conditional: "condicional"
|
||||
condition: "condición"
|
||||
isConditionalRole: "Esto es un rol condicional"
|
||||
isPublic: "Publicar rol"
|
||||
descriptionOfIsPublic: "Cualquiera puede ver los usuarios asignados a este rol. También, el perfil del usuario mostrará este rol."
|
||||
options: "Opción"
|
||||
policies: "Política"
|
||||
baseRole: "Rol base"
|
||||
useBaseValue: "Usar los valores del rol base"
|
||||
chooseRoleToAssign: "Selecciona el rol para asignar"
|
||||
canEditMembersByModerator: "Permitir a los moderadores editar los miembros"
|
||||
descriptionOfCanEditMembersByModerator: "Si se activa, los moderadores, al igual que los administradores, serán capaces de asignar/quitar usuarios a éste rol. Si se desactiva, sólo los administradores podrán hacerlo."
|
||||
priority: "Prioridad"
|
||||
_priority:
|
||||
low: "Baja"
|
||||
middle: "Mediano"
|
||||
high: "Alta"
|
||||
_options:
|
||||
gtlAvailable: "Explorar la línea de tiempo global"
|
||||
ltlAvailable: "Explorar la línea de tiempo local"
|
||||
canPublicNote: "Permitir la publicación"
|
||||
canInvite: "Puede crear códigos de invitación"
|
||||
canManageCustomEmojis: "Administrar emojis personalizados"
|
||||
driveCapacity: "Capacidad de almacenamiento"
|
||||
pinMax: "Máximo de notas fijadas"
|
||||
antennaMax: "Máximo de antenas"
|
||||
wordMuteMax: "Máximo de caracteres en palabras silenciadas"
|
||||
webhookMax: "Máximo de Webhooks"
|
||||
clipMax: "Máximo de clips"
|
||||
noteEachClipsMax: "Máximo de notas con clip"
|
||||
userListMax: "Máximo de listas de usuarios"
|
||||
userEachUserListsMax: "Máximo de usuarios en una lista"
|
||||
rateLimitFactor: "Limitador"
|
||||
descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos"
|
||||
canHideAds: "Puede ocultar anuncios"
|
||||
_condition:
|
||||
isLocal: "Usuario local"
|
||||
isRemote: "Usuario remoto"
|
||||
createdLessThan: "Menos de X han pasado desde la creación de la cuenta"
|
||||
createdMoreThan: "Más de X han pasado desde la creación de la cuenta"
|
||||
followersLessThanOrEq: "Tiene X o menos seguidores"
|
||||
followersMoreThanOrEq: "Tiene X o más seguidores"
|
||||
followingLessThanOrEq: "Sigue X o menos cuentas"
|
||||
followingMoreThanOrEq: "Sigue X o más cuentas"
|
||||
and: "Condicional AND"
|
||||
or: "Condicional OR"
|
||||
not: "Condicional NOT"
|
||||
_sensitiveMediaDetection:
|
||||
description: "Reduce el esfuerzo de la moderación el el servidor a través del reconocimiento automático de contenido NSFW usando 'Machine Learning'. Esto puede incrementar ligeramente la carga en el servidor."
|
||||
sensitivity: "Sensibilidad de detección"
|
||||
@@ -1328,10 +1634,12 @@ _widgets:
|
||||
jobQueue: "Cola de trabajos"
|
||||
serverMetric: "Estadísticas del servidor"
|
||||
aiscript: "Consola de AiScript"
|
||||
aiscriptApp: "Aplicación AiScript"
|
||||
aichan: "indigo"
|
||||
userList: "Lista de usuarios"
|
||||
_userList:
|
||||
chooseList: "Seleccione una lista"
|
||||
clicker: "Cliqueador"
|
||||
_cw:
|
||||
hide: "Ocultar"
|
||||
show: "Ver más"
|
||||
@@ -1434,7 +1742,16 @@ _timelines:
|
||||
social: "Social"
|
||||
global: "Global"
|
||||
_play:
|
||||
new: "Crear guión"
|
||||
edit: "Editar guión"
|
||||
created: "Guión creado"
|
||||
updated: "Guión editado"
|
||||
deleted: "Guión eliminado"
|
||||
pageSetting: "Configuración de guión"
|
||||
editThisPage: "Editar este guión"
|
||||
viewSource: "Ver la fuente"
|
||||
my: "Mis guiones"
|
||||
liked: "Guiones que te gustaron"
|
||||
featured: "Popular"
|
||||
title: "Título"
|
||||
script: "Script"
|
||||
@@ -1507,6 +1824,7 @@ _notification:
|
||||
pollEnded: "Estan disponibles los resultados de la encuesta"
|
||||
unreadAntennaNote: "Antena {name}"
|
||||
emptyPushNotificationMessage: "Se han actualizado las notificaciones push"
|
||||
achievementEarned: "Logro desbloqueado"
|
||||
_types:
|
||||
all: "Todo"
|
||||
follow: "Siguiendo"
|
||||
|
@@ -13,6 +13,7 @@ fetchingAsApObject: "Mengambil data dari Fediverse..."
|
||||
ok: "OK"
|
||||
gotIt: "Saya mengerti"
|
||||
cancel: "Batalkan"
|
||||
noThankYou: "Tidak sekarang."
|
||||
enterUsername: "Masukkan nama pengguna"
|
||||
renotedBy: "direnote oleh {user}"
|
||||
noNotes: "Tidak ada catatan"
|
||||
@@ -206,6 +207,7 @@ done: "Selesai"
|
||||
processing: "Memproses"
|
||||
preview: "Pratinjau"
|
||||
default: "Bawaan"
|
||||
defaultValueIs: "Bawaan: {value}"
|
||||
noCustomEmojis: "Tidak ada emoji kustom"
|
||||
noJobs: "Tidak ada kerja"
|
||||
federating: "memfederasi"
|
||||
@@ -349,6 +351,8 @@ recaptcha: "reCAPTCHA"
|
||||
enableRecaptcha: "Nyalakan reCAPTCHA"
|
||||
recaptchaSiteKey: "Site key"
|
||||
recaptchaSecretKey: "Secret Key"
|
||||
turnstile: "Turnstile"
|
||||
enableTurnstile: "Nyalakan Turnstile"
|
||||
turnstileSiteKey: "Site key"
|
||||
turnstileSecretKey: "Secret Key"
|
||||
avoidMultiCaptchaConfirm: "Menggunakan banyak Captcha dapat menyebabkan gangguan. Apakah kamu ingin untuk menonaktifkan Captcha yang lain? Kamu dapat membiarkan fitur ini tetap aktif dengan menekan tombol batal."
|
||||
@@ -454,6 +458,7 @@ uiLanguage: "Bahasa antarmuka pengguna"
|
||||
groupInvited: "Telah diundang ke grup"
|
||||
aboutX: "Tentang {x}"
|
||||
emojiStyle: "Gaya emoji"
|
||||
native: "Native"
|
||||
disableDrawer: "Jangan gunakan menu bergaya laci"
|
||||
youHaveNoGroups: "Kamu tidak memiliki grup"
|
||||
joinOrCreateGroup: "Bergabunglah dengan grup atau kamu dapat membuat grupmu sendiri."
|
||||
@@ -857,10 +862,21 @@ rateLimitExceeded: "Batas sudah terlampaui"
|
||||
cropImage: "potong gambar"
|
||||
cropImageAsk: "Ingin memotong gambar?"
|
||||
file: "Berkas"
|
||||
recentNHours: "{n} jam terakhir"
|
||||
recentNDays: "{n} hari terakhir"
|
||||
noEmailServerWarning: "Mail Server tidak disetel."
|
||||
thereIsUnresolvedAbuseReportWarning: "Ada laporan yang belum diselesaikan."
|
||||
recommended: "Disarankan"
|
||||
check: "Cek"
|
||||
driveCapOverrideLabel: "Ubah kapasitas drive untuk user ini"
|
||||
driveCapOverrideCaption: "Setel ulang kapasitas ke bawaan dengan memasukkan nilai 0 atau lebih rendah."
|
||||
requireAdminForView: "Kamu harus login dengan akun administrator untuk melihat ini."
|
||||
isSystemAccount: "Akun yang dibuat dan otomatis dioperasikan oleh sistem."
|
||||
typeToConfirm: "Mohon masukkan {x} untuk mengonfirmasi"
|
||||
deleteAccount: "Hapus Akun"
|
||||
document: "Dokumen"
|
||||
numberOfPageCache: "Jumlah halaman ditembolokkan"
|
||||
numberOfPageCacheDescription: "Menaikkan jumlah ini akan meningkatkan kenyamanan untuk pengguna, namun dapat menyebabkan lonjakan beban pada peladen dan juga memori yang digunakan."
|
||||
logoutConfirm: "Anda yakin ingin keluar?"
|
||||
lastActiveDate: "Terakhir digunakan"
|
||||
statusbar: "Bilah status"
|
||||
@@ -870,20 +886,189 @@ colored: "Diwarnai"
|
||||
refreshInterval: "Jeda pembaharuan"
|
||||
label: "Label"
|
||||
type: "Tipe"
|
||||
speed: "Kecepatan"
|
||||
slow: "Lambat"
|
||||
fast: "Cepat"
|
||||
sensitiveMediaDetection: "Deteksi media NSFW"
|
||||
localOnly: "Hanya lokal"
|
||||
remoteOnly: "Hanya remot"
|
||||
failedToUpload: "Gagal mengunggah"
|
||||
cannotUploadBecauseInappropriate: "Berkas ini tidak dapat diunggah karena sebagian dari berkas terdeteksi berpotensi NSFW."
|
||||
cannotUploadBecauseNoFreeSpace: "Gagal mengunggah karena kekurangan kapasitas Drive."
|
||||
beta: "Beta"
|
||||
enableAutoSensitive: "Penandaan NSFW otomatis"
|
||||
enableAutoSensitiveDescription: "Mendeteksi otomatis dan menandai media NSFW menggunakan Machine Learning jika memungkinkan. Meskipun opsi ini dimatikan, ada kemungkinan dinyalakan secara menyeluruh pada instansi peladen."
|
||||
activeEmailValidationDescription: "Membolehkan validasi alamat surel ketat dengan mengecek apakah alamat surel tersebut temporer dan bisa berkomunikasi dengan surel tersebut. Ketidak tidak dicentang, hanya format surel yang divalidasi."
|
||||
navbar: "Bilah navigasi"
|
||||
shuffle: "Acak"
|
||||
account: "Akun"
|
||||
move: "Pindah"
|
||||
pushNotification: "Pemberitahuan push"
|
||||
subscribePushNotification: "Nyalakan pemberitahuan push"
|
||||
unsubscribePushNotification: "Matikan pemberitahuan push"
|
||||
pushNotificationAlreadySubscribed: "Pemberitahuan push telah dinyalakan"
|
||||
pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung pemberitahuan push"
|
||||
sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca"
|
||||
sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu."
|
||||
windowMaximize: "Maksimalkan"
|
||||
windowRestore: "Kembalikan"
|
||||
caption: "Keterangan"
|
||||
loggedInAsBot: "Sedang login sebagai bot"
|
||||
tools: "Alat"
|
||||
cannotLoad: "Tidak dapat memuat"
|
||||
numberOfProfileView: "tayang profil"
|
||||
like: "Suka"
|
||||
unlike: "Tidak Suka"
|
||||
numberOfLikes: "Jumlah yang disukai"
|
||||
show: "Tampilkan"
|
||||
neverShow: "Jangan tampilkan lagi"
|
||||
remindMeLater: "Mungkin nanti"
|
||||
didYouLikeMisskey: "Apakah kamu mulai menyukai Misskey?"
|
||||
pleaseDonate: "{host} menggunakan perangkat lunak bebas yaitu Misskey. Kami sangat mengapresiasi sekali donasi dari kamu agar pengembangan Misskey tetap dapat berlanjut!"
|
||||
roles: "Peran"
|
||||
role: "Peran"
|
||||
color: "Warna"
|
||||
_achievements:
|
||||
_types:
|
||||
_login7:
|
||||
description: "Login selama 7 hari"
|
||||
flavor: "Sudah mulai terbiasa?"
|
||||
_login15:
|
||||
title: "Pemula III"
|
||||
description: "Login selama 15 hari"
|
||||
_login30:
|
||||
title: "Misskist I"
|
||||
description: "Login selama 30 hari"
|
||||
_login60:
|
||||
title: "Misskist II"
|
||||
description: "Login selama 60 hari"
|
||||
_login100:
|
||||
title: "Misskist III"
|
||||
description: "Login selama 100 hari"
|
||||
flavor: "Violent Misskist"
|
||||
_login200:
|
||||
title: "Reguler I"
|
||||
description: "Login selama 200 hari"
|
||||
_login300:
|
||||
title: "Reguler II"
|
||||
description: "Login selama 300 hari"
|
||||
_login400:
|
||||
title: "Reguler III"
|
||||
description: "Login selama 400 hari"
|
||||
_login500:
|
||||
title: "Veteran I"
|
||||
description: "Login selama 500 hari"
|
||||
flavor: "Kawanku, aku suka catatan."
|
||||
_login600:
|
||||
title: "Veteran II"
|
||||
description: "Login selama 600 hari"
|
||||
_login700:
|
||||
title: "Veteran III"
|
||||
description: "Login selama 700 hari"
|
||||
_login800:
|
||||
title: "Sepuh Catatan I"
|
||||
description: "Login selama 800 hari"
|
||||
_login900:
|
||||
title: "Sepuh Catatan II"
|
||||
description: "Login selama 900 hari"
|
||||
_login1000:
|
||||
title: "Sepuh Catatan III"
|
||||
description: "Login selama 1000 hari"
|
||||
flavor: "Terima kasih telah menggunakan Misskey!"
|
||||
_noteClipped1:
|
||||
title: "Harus... Ngeklip..."
|
||||
description: "Klip catatan pertamamu"
|
||||
_noteFavorited1:
|
||||
title: "Pengamat Bintang"
|
||||
description: "Favoritkan catatan pertamamu"
|
||||
_myNoteFavorited1:
|
||||
title: "Pencari Bintang"
|
||||
description: "Minta orang lain memfavoritkan salah satu catatanmu"
|
||||
_profileFilled:
|
||||
title: "Siap Sedia"
|
||||
description: "Atur profil kamu"
|
||||
_markedAsCat:
|
||||
title: "Aku Seekor Kucing"
|
||||
description: "Tandai akunmu sebagai kucing"
|
||||
flavor: "Aku beri kamu nama nanti"
|
||||
_following1:
|
||||
title: "Ikuti pengguna lain pertamamu"
|
||||
description: "Ikuti pengguna"
|
||||
_following10:
|
||||
title: "Terusin... terusin..."
|
||||
description: "Ikuti 10 pengguna lain"
|
||||
_following50:
|
||||
title: "Banyak teman"
|
||||
description: "Ikuti 50 pengguna lain"
|
||||
_following100:
|
||||
title: "100 Teman"
|
||||
description: "Ikuti 100 pengguna lain"
|
||||
_clickedClickHere:
|
||||
description: "Kamu telah mengeklik disini"
|
||||
_justPlainLucky:
|
||||
title: "Lagi Beruntung"
|
||||
description: "Mendapatkan kesempatan dengan kemungkinan 0.01% setiap 10 detik"
|
||||
_setNameToSyuilo:
|
||||
title: "God Complex"
|
||||
description: "Atur namamu jadi \"syuilo\""
|
||||
_passedSinceAccountCreated1:
|
||||
title: "Perayaan Satu Tahun"
|
||||
description: "Satu tahun telah lewat sejak akunmu dibuat"
|
||||
_passedSinceAccountCreated2:
|
||||
title: "Perayaan Dua Tahun"
|
||||
description: "Dua tahun telah lewat sejak akunmu dibuat"
|
||||
_passedSinceAccountCreated3:
|
||||
title: "Perayaan Tiga Tahun"
|
||||
description: "Tiga tahun telah lewat sejak akunmu dibuat"
|
||||
_loggedInOnBirthday:
|
||||
title: "Selamat Ulang Tahun"
|
||||
description: "Login di hari ulang tahunmu"
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "Selamat Tahun Baru!"
|
||||
description: "Login di hari pertama tahun baru"
|
||||
_cookieClicked:
|
||||
title: "Permainan dimana kamu mengeklik kue"
|
||||
description: "Mengeklik kue"
|
||||
flavor: "Tunggu, apakah kamu sedang berada di website yang benar?"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
description: "Posting tautan mengenai Brain Diver"
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
_role:
|
||||
new: "Buat peran"
|
||||
edit: "Sunting peran"
|
||||
name: "Nama peran"
|
||||
description: "Deskripsi peran"
|
||||
permission: "Perijinan peran"
|
||||
descriptionOfPermission: "<b>Moderator</b> dapat melakukan operasi moderasi dasar.\n<b>Administrator</b> dapat mengubah seluruh pengaturan instansi."
|
||||
assignTarget: "Tipe tugas"
|
||||
descriptionOfAssignTarget: "<b>Manual</b> untuk mengganti secara manual siapa yang mendapatkan peran ini dan siapa yang tidak.\n<b>Kondisional</b> untuk pengguna secara otomatis dimasukkan atau dihapus dari peran berdasarkan kondisi yang ditentukan."
|
||||
manual: "Manual"
|
||||
conditional: "Kondisional"
|
||||
condition: "Kondisi"
|
||||
isConditionalRole: "Ini adalah peran kondisional"
|
||||
isPublic: "Publikkan Peran"
|
||||
descriptionOfIsPublic: "Siapapun dapat melihat daftar pengguna yang ditugaskan pada peran ini. Tambahan juga peran ini akan ditampilkan ke dalam profil pengguna tentang peran yang ditugaskan."
|
||||
options: "Opsi peran"
|
||||
policies: "Kebijakan"
|
||||
baseRole: "Templat peran"
|
||||
useBaseValue: "Gunakan nilai templat peran"
|
||||
chooseRoleToAssign: "Pilih peran yang ditugaskan"
|
||||
canEditMembersByModerator: "Perbolehkan moderator untuk menyunting daftar anggota untuk peran ini"
|
||||
descriptionOfCanEditMembersByModerator: "Ketika dinyalakan, moderator beserta administrator dapat menugaskan ataupun mencabut pengguna ke peran ini. Ketika dimatikan, hanya administrator saja yang dapat menugaskan pengguna ke peran ini."
|
||||
priority: "Prioritas"
|
||||
_priority:
|
||||
low: "Rendah"
|
||||
middle: "Sedang"
|
||||
high: "Tinggi"
|
||||
_options:
|
||||
gtlAvailable: "Dapat melihat linimasa global"
|
||||
ltlAvailable: "Dapat melihat linimasa lokal"
|
||||
canPublicNote: "Dapat mengirim catatan publik"
|
||||
canInvite: "Dapat membuat kode undangan instansi"
|
||||
canManageCustomEmojis: "Dapat mengelola Emoji kustom"
|
||||
driveCapacity: "Kapasitas Drive"
|
||||
pinMax: "Jumlah maksimal catatan yang disematkan"
|
||||
_emailUnavailable:
|
||||
used: "Alamat surel ini telah digunakan"
|
||||
format: "Format tidak valid."
|
||||
@@ -1167,6 +1352,7 @@ _tutorial:
|
||||
step7_1: "Yay, Selamat! Kamu sudah menyelesaikan tutorial dasar Misskey."
|
||||
step7_2: "Jika kamu ingin mempelajari lebih lanjut tentang Misskey, cobalah berkunjung ke bagian {help}."
|
||||
step7_3: "Semoga berhasil dan bersenang-senanglah! 🚀"
|
||||
step8_3: "Kamu dapat mengganti pengaturan ini nanti."
|
||||
_2fa:
|
||||
alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor."
|
||||
registerDevice: "Daftarkan perangkat baru"
|
||||
@@ -1241,10 +1427,13 @@ _widgets:
|
||||
trends: "Tren"
|
||||
clock: "Jam"
|
||||
rss: "Pembaca RSS"
|
||||
rssTicker: "RSS-Ticker"
|
||||
activity: "Aktivitas"
|
||||
photos: "Foto"
|
||||
digitalClock: "Jam digital"
|
||||
unixClock: "Jam UNIX"
|
||||
federation: "Federasi"
|
||||
instanceCloud: "Instansi awan"
|
||||
postForm: "Buat catatan"
|
||||
slideshow: "Slideshow"
|
||||
button: "Tombol"
|
||||
@@ -1254,8 +1443,10 @@ _widgets:
|
||||
aiscript: "Konsol AiScript"
|
||||
aiscriptApp: "Aplikasi AiScript"
|
||||
aichan: "Ai"
|
||||
userList: "Daftar pengguna"
|
||||
_userList:
|
||||
chooseList: "Pilih daftar"
|
||||
clicker: "Pengeklik"
|
||||
_cw:
|
||||
hide: "Sembunyikan"
|
||||
show: "Lihat konten"
|
||||
@@ -1319,6 +1510,7 @@ _profile:
|
||||
changeBanner: "Ubah header"
|
||||
_exportOrImport:
|
||||
allNotes: "Semua catatan"
|
||||
favoritedNotes: "Catatan favorit"
|
||||
followingList: "Ikuti"
|
||||
muteList: "Bisukan"
|
||||
blockingList: "Blokir"
|
||||
@@ -1437,7 +1629,9 @@ _notification:
|
||||
yourFollowRequestAccepted: "Permintaan mengikuti kamu telah diterima"
|
||||
youWereInvitedToGroup: "Telah diundang ke grup"
|
||||
pollEnded: "Hasil Kuesioner telah keluar"
|
||||
unreadAntennaNote: "Antena {name}"
|
||||
emptyPushNotificationMessage: "Pembaruan notifikasi dorong"
|
||||
achievementEarned: "Pencapaian didapatkan"
|
||||
_types:
|
||||
all: "Semua"
|
||||
follow: "Ikuti"
|
||||
@@ -1459,6 +1653,7 @@ _deck:
|
||||
alwaysShowMainColumn: "Selalu tampilkan kolom utama"
|
||||
columnAlign: "Luruskan kolom"
|
||||
addColumn: "Tambahkan kolom"
|
||||
configureColumn: "Atur kolom"
|
||||
swapLeft: "Pindah ke kiri"
|
||||
swapRight: "Pindah ke kanan"
|
||||
swapUp: "Pindah ke atas"
|
||||
@@ -1466,6 +1661,11 @@ _deck:
|
||||
stackLeft: "Tumpukkan di kolom kiri"
|
||||
popRight: "Keluarkan di kanan"
|
||||
profile: "Profil"
|
||||
newProfile: "Profil baru"
|
||||
deleteProfile: "Hapus profil"
|
||||
introduction: "Buat antarmuka sempurna untukmu dengan menata kolom secara bebas!"
|
||||
introduction2: "Klik \"+\" pada kanan layar untuk menambahkan kolom baru kapanpun yang kamu mau."
|
||||
widgetsIntroduction: "Mohon pilih \"Sunting gawit\" pada menu kolom dan tambahkan gawit."
|
||||
_columns:
|
||||
main: "Utama"
|
||||
widgets: "Widget"
|
||||
|
@@ -22,7 +22,7 @@ instance: "Инстанс"
|
||||
settings: "Настройки"
|
||||
basicSettings: "Основные настройки"
|
||||
otherSettings: "Прочие настройки"
|
||||
openInWindow: "Открывать в плавающих окнах"
|
||||
openInWindow: "Открыть в плавающем окне"
|
||||
profile: "Профиль"
|
||||
timeline: "Лента"
|
||||
noAccountDescription: "Пользователь ничего не написал про себя"
|
||||
@@ -273,7 +273,7 @@ light: "Светлый"
|
||||
dark: "Тёмный"
|
||||
lightThemes: "Светлые темы"
|
||||
darkThemes: "Тёмные темы"
|
||||
syncDeviceDarkMode: "Синхронизировать с темным режимом устройства"
|
||||
syncDeviceDarkMode: "Синхронизировать с тёмной темой системы"
|
||||
drive: "Диск"
|
||||
fileName: "Имя файла"
|
||||
selectFile: "Выберите файл"
|
||||
@@ -456,6 +456,7 @@ uiLanguage: "Язык интерфейса"
|
||||
groupInvited: "Приглашение в группу"
|
||||
aboutX: "Описание {x}"
|
||||
emojiStyle: "Стиль эмодзи"
|
||||
native: "Системные"
|
||||
disableDrawer: "Не использовать выдвижные меню"
|
||||
youHaveNoGroups: "У вас нет ни одной группы"
|
||||
joinOrCreateGroup: "Получайте приглашения в группы или создавайте свои собственные"
|
||||
@@ -603,6 +604,7 @@ smtpSecureInfo: "Выключите при использовании STARTTLS."
|
||||
testEmail: "Проверка доставки электронной почты"
|
||||
wordMute: "Скрытие слов"
|
||||
regexpError: "Ошибка в регулярном выражении"
|
||||
regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:"
|
||||
instanceMute: "Глушение инстансов"
|
||||
userSaysSomething: "{name} что-то сообщает"
|
||||
makeActive: "Активировать"
|
||||
@@ -804,7 +806,7 @@ translate: "Перевод"
|
||||
translatedFrom: "Перевод. Язык оригинала — {x}"
|
||||
accountDeletionInProgress: "В настоящее время выполняется удаление учетной записи"
|
||||
usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания (_). Имена пользователей не могут быть изменены позже."
|
||||
aiChanMode: "ИИ режим"
|
||||
aiChanMode: "Режим Ай"
|
||||
keepCw: "Сохраняйте Предупреждения о содержимом"
|
||||
pubSub: "Учётные записи Pub/Sub"
|
||||
lastCommunication: "Последнее сообщение"
|
||||
@@ -821,8 +823,8 @@ manageAccounts: "Управление аккаунтом"
|
||||
makeReactionsPublic: "Опубликовать список реакций"
|
||||
makeReactionsPublicDescription: "Список сделанных вами реакций доступен для просмотра всем желающим."
|
||||
classic: "Классика"
|
||||
muteThread: "Заглушить цепочку"
|
||||
unmuteThread: "Отменить глушение цепочки"
|
||||
muteThread: "Скрыть цепочку"
|
||||
unmuteThread: "Отменить сокрытие цепочки"
|
||||
ffVisibility: "Видимость подписок и подписчиков"
|
||||
ffVisibilityDescription: "Здесь можно настроить, кто будет видеть ваши подписки и подписчиков."
|
||||
continueThread: "Показать следующие ответы"
|
||||
@@ -891,6 +893,7 @@ cannotUploadBecauseNoFreeSpace: "Файл не может быть загруж
|
||||
beta: "Бета"
|
||||
enableAutoSensitive: "Автоматическое определение NSFW"
|
||||
enableAutoSensitiveDescription: "Если доступно, используйте машинное обучение для автоматической установки флага NSFW на носителе. Даже если эта функция отключена, она может быть установлена автоматически в зависимости от инстанта."
|
||||
activeEmailValidationDescription: "Если включено, будет проводиться более строгая проверка адреса электронной почты, в том числе на то, что он действительный и не временный. Если же отключено, то проверяется только корректность написания адреса."
|
||||
navbar: "Панель навигации"
|
||||
shuffle: "Перемешать"
|
||||
account: "Учётные записи"
|
||||
@@ -1096,6 +1099,9 @@ _achievements:
|
||||
title: "Я люблю Misskey"
|
||||
description: "Написана заметка «I ❤ #Misskey»"
|
||||
flavor: "Спасибо за поддержку Misskey! Ваша команда разработчиков"
|
||||
_foundTreasure:
|
||||
title: "Охота за сокровищами"
|
||||
description: "Найдено спрятанное сокровище"
|
||||
_client30min:
|
||||
title: "Перерыв на обед"
|
||||
description: "Прошло 30 минут с момента запуска клиента"
|
||||
@@ -1116,6 +1122,9 @@ _achievements:
|
||||
_htl20npm:
|
||||
title: "В потоке"
|
||||
description: "Достигнута скорость домашней ленты в 20 з/мин (заметок минуту)"
|
||||
_viewInstanceChart:
|
||||
title: "Аналитик"
|
||||
description: "Просмотрены статистические диаграммы инстанса"
|
||||
_outputHelloWorldOnScratchpad:
|
||||
title: "Привет, мир!"
|
||||
description: "Выведен текст «hello world» в Когтеточке"
|
||||
@@ -1189,7 +1198,34 @@ _role:
|
||||
middle: "Средне"
|
||||
high: "Высокий"
|
||||
_options:
|
||||
gtlAvailable: "Может просматривать глобальную ленту"
|
||||
ltlAvailable: "Может просматривать местную ленту"
|
||||
canPublicNote: "Может публиковать общедоступные заметки"
|
||||
canInvite: "Может создавать пригласительные коды"
|
||||
canManageCustomEmojis: "Управлять пользовательскими эмодзи"
|
||||
driveCapacity: "Доступное пространство на «диске»"
|
||||
pinMax: "Доступное количество закреплённых заметок"
|
||||
antennaMax: "Доступное количество антенн"
|
||||
wordMuteMax: "Доступное количество знаков в списке скрытия слов"
|
||||
clipMax: "Максимальное количество подборок"
|
||||
noteEachClipsMax: "Максимальное количество заметок в подборке"
|
||||
userListMax: "Максимальное количество списков аккаунтов"
|
||||
userEachUserListsMax: "Максимальное количество аккаунтов в списке"
|
||||
rateLimitFactor: "Ограничение активности"
|
||||
descriptionOfRateLimitFactor: "Меньшее значение — слабые ограничения, большее — сильные"
|
||||
canHideAds: "Может скрыть рекламу"
|
||||
_condition:
|
||||
isLocal: "Местный"
|
||||
isRemote: "Неместный"
|
||||
createdLessThan: "Аккаунт младше, чем..."
|
||||
createdMoreThan: "Аккаунт старше, чем..."
|
||||
followersLessThanOrEq: "Количество подписчиков не превышает…"
|
||||
followersMoreThanOrEq: "Количество подписчиков не меньше чем…"
|
||||
followingLessThanOrEq: "Количество подписок не превышает…"
|
||||
followingMoreThanOrEq: "Количество подписок не меньше чем…"
|
||||
and: "Выполнено несколько условий:.."
|
||||
or: "Выполнено любое из условий:.."
|
||||
not: "Кроме тех, у кого…"
|
||||
_sensitiveMediaDetection:
|
||||
description: "Машинное обучение может быть использовано для автоматического обнаружения чувствительных медиа для модерации. Нагрузка на сервер увеличивается незначительно."
|
||||
setSensitiveFlagAutomatically: "Установить флаг NSFW"
|
||||
@@ -1237,10 +1273,23 @@ _plugin:
|
||||
installWarn: "Пожалуйста, не устанавливайте расширения, которым не доверяете."
|
||||
manage: "Управление расширениями"
|
||||
_preferencesBackups:
|
||||
saveConfirm: "Сохранить бэкап как {name}?"
|
||||
deleteConfirm: "Удалить резервную копию {name}?"
|
||||
renameConfirm: "Переименовать резервную копию с \"{old}\" на \"{new}\"?"
|
||||
noBackups: "Резервной копии не существует. Вы можете создать резервную копию в настройках на этом инстансе с помощью \"Создать новую резервную копию\"."
|
||||
list: "Существующие резервные копии"
|
||||
saveNew: "Создать резервную копию"
|
||||
loadFile: "Прочесть из файла"
|
||||
apply: "Восстановить на это устройство"
|
||||
save: "Обновить из текущих настроек"
|
||||
inputName: "Введите название для резервной копии"
|
||||
cannotSave: "Сохранить не удалось"
|
||||
nameAlreadyExists: "Резервная копия под названием «{name}» уже существует. Придумайте другое."
|
||||
applyConfirm: "Правда хотите загрузить резервную копию «{name}» на это устройство? Этим будут потеряны текущие настройки."
|
||||
saveConfirm: "Сохранить резервную копию под названием «{name}»?"
|
||||
deleteConfirm: "Удалить резервную копию «{name}»?"
|
||||
renameConfirm: "Переименовать резервную копию «{old}» в «{new}»?"
|
||||
noBackups: "Здесь ещё нет резервных копий. Вы можете создать резервную копию настроек на этом сайте с помощью кнопки «Создать резервную копию»."
|
||||
createdAt: "Создана {date} в {time}"
|
||||
updatedAt: "Обновлена {date} в {time}"
|
||||
cannotLoad: "Загрузить не удалось"
|
||||
invalidFile: "Некорректный формат файла"
|
||||
_registry:
|
||||
scope: "Область"
|
||||
key: "Ключ"
|
||||
@@ -1324,6 +1373,8 @@ _mfm:
|
||||
sparkleDescription: "Добавляет эффект искрящихся частиц."
|
||||
rotate: "Повернуть"
|
||||
rotateDescription: "Поворачивает на заданный угол."
|
||||
plain: "Буквально"
|
||||
plainDescription: "MFM внутри отключается, и текст отображается как есть"
|
||||
_instanceTicker:
|
||||
none: "Не показывать"
|
||||
remote: "Только для других сайтов"
|
||||
@@ -1353,12 +1404,14 @@ _wordMute:
|
||||
muteWordsDescription2: "Здесь можно использовать регулярные выражения — просто заключите их между двумя дробными чертами (/)."
|
||||
softDescription: "Соответствующие условиям заметки будут спрятаны из вашей ленты."
|
||||
hardDescription: "Соответстующие условиям заметки вообще не будут попадать в вашу ленту. Даже если вы поменяете условия, отсеенные таким образом заметки уже не появятся."
|
||||
soft: "Мягкий"
|
||||
hard: "Жёсткий"
|
||||
soft: "Мягко"
|
||||
hard: "Жёстко"
|
||||
mutedNotes: "Скрытые заметки"
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Заметки и репосты с указанных здесь инстансов, а также ответы пользователям оттуда же не будут отображаться."
|
||||
instanceMuteDescription2: "Пишите каждый инстанс на отдельной строке"
|
||||
title: "Скрывает заметки с заданных инстансов."
|
||||
heading: "Список заглушенных инстансов"
|
||||
heading: "Список скрытых инстансов"
|
||||
_theme:
|
||||
explore: "Обзор"
|
||||
install: "Установить тему"
|
||||
@@ -1479,12 +1532,16 @@ _tutorial:
|
||||
step7_1: "На этом вводный урок по использованию Misskey закончен. Спасибо, что прошли его до конца!"
|
||||
step7_2: "Хотите изучить Misskey глубже — добро пожаловать в раздел «{help}»."
|
||||
step7_3: "Приятно вам провести время с Misskey🚀"
|
||||
step8_1: "Ах, да, не хотите ли включить push-уведомления?"
|
||||
step8_2: "С push-уведомлениями вы будете в курсе репостов, ответов, реакций и всего такого, даже когда закрыли Misskey."
|
||||
step8_3: "Эту настройку вы всегда сможете поменять"
|
||||
_2fa:
|
||||
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
|
||||
registerDevice: "Зарегистрируйте ваше устройство"
|
||||
registerKey: "Зарегистрировать ключ"
|
||||
step1: "Прежде всего, установите на устройство приложение для аутентификации, например, {a} или {b}."
|
||||
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."
|
||||
step2Url: "Если пользуетесь приложением на компьютере, можете ввести в него эту строку (URL):"
|
||||
step3: "И наконец, введите код, который покажет приложение."
|
||||
step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения аналогичным образом."
|
||||
securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве."
|
||||
@@ -1501,7 +1558,7 @@ _permissions:
|
||||
"write:following": "Изменять спискок подписок"
|
||||
"read:messaging": "Смотреть сообщения"
|
||||
"write:messaging": "Писать и удалять сообщения"
|
||||
"read:mutes": "Смотреть спискок скрытых пользователей"
|
||||
"read:mutes": "Смотреть список скрытых пользователей"
|
||||
"write:mutes": "Изменять список скрытых пользователей"
|
||||
"write:notes": "Писать и удалять заметки"
|
||||
"read:notifications": "Смотреть уведомления"
|
||||
@@ -1552,10 +1609,13 @@ _widgets:
|
||||
trends: "Актуальное"
|
||||
clock: "Часы"
|
||||
rss: "Просмотр RSS"
|
||||
rssTicker: "Бегущая строка RSS"
|
||||
activity: "Активность"
|
||||
photos: "Фото"
|
||||
digitalClock: "Цифровые часы"
|
||||
unixClock: "Часы UNIX"
|
||||
federation: "Федерация"
|
||||
instanceCloud: "Облако инстансов"
|
||||
postForm: "Форма отправки"
|
||||
slideshow: "Показ слайдов"
|
||||
button: "Кнопка"
|
||||
@@ -1563,9 +1623,12 @@ _widgets:
|
||||
jobQueue: "Очередь заданий"
|
||||
serverMetric: "Показатели сервера"
|
||||
aiscript: "Консоль AiScript"
|
||||
aiscriptApp: "Приложение на AiScript"
|
||||
aichan: "Ай"
|
||||
userList: "Список аккаунтов"
|
||||
_userList:
|
||||
chooseList: "Выберите список"
|
||||
clicker: "Счётчик щелчков"
|
||||
_cw:
|
||||
hide: "Спрятать"
|
||||
show: "Показать еще"
|
||||
@@ -1628,12 +1691,13 @@ _profile:
|
||||
changeAvatar: "Поменять аватар"
|
||||
changeBanner: "Поменять изображение в шапке"
|
||||
_exportOrImport:
|
||||
allNotes: "Все записи\n"
|
||||
allNotes: "Все заметки\n"
|
||||
favoritedNotes: "Избранное"
|
||||
followingList: "Подписки"
|
||||
muteList: "Скрытые"
|
||||
blockingList: "Заблокированные"
|
||||
userLists: "Списки"
|
||||
excludeMutingUsers: "За исключением заглушенных пользователей"
|
||||
excludeMutingUsers: "За исключением скрытых пользователей"
|
||||
excludeInactiveUsers: "Без неактивных учётных записей"
|
||||
_charts:
|
||||
federation: "Федерация"
|
||||
@@ -1737,6 +1801,8 @@ _notification:
|
||||
youReceivedFollowRequest: "У вас новый запрос на подписку."
|
||||
yourFollowRequestAccepted: "Ваш запрос на подписку одобрен."
|
||||
youWereInvitedToGroup: "Вы приглашены в группу."
|
||||
pollEnded: "Подведены окончательные итоги опроса"
|
||||
emptyPushNotificationMessage: "Обновлены push-уведомления"
|
||||
achievementEarned: "Получено достижение"
|
||||
_types:
|
||||
all: "Все"
|
||||
@@ -1746,11 +1812,13 @@ _notification:
|
||||
renote: "Репосты"
|
||||
quote: "Цитаты"
|
||||
reaction: "Реакции"
|
||||
pollEnded: "Окончания опросов"
|
||||
receiveFollowRequest: "Получен запрос на подписку"
|
||||
followRequestAccepted: "Запрос на подписку одобрен"
|
||||
groupInvited: "Приглашение в группы"
|
||||
app: "Уведомления из приложений"
|
||||
_actions:
|
||||
followBack: "отвечает взаимной подпиской"
|
||||
reply: "Ответить"
|
||||
renote: "Репост"
|
||||
_deck:
|
||||
@@ -1764,7 +1832,12 @@ _deck:
|
||||
swapDown: "Переставить ниже"
|
||||
stackLeft: "В столбик влево"
|
||||
popRight: "Из столбика вправо"
|
||||
profile: "Профиль"
|
||||
profile: "Расстановка"
|
||||
newProfile: "Новая расстановка"
|
||||
deleteProfile: "Удаление расстановки"
|
||||
introduction: "Создайте идеальный интерфейс расставляя колонки как угодно"
|
||||
introduction2: "Чтобы добавлять колонки в любом месте, жмите «+» справа экрана."
|
||||
widgetsIntroduction: "Чтобы добавлять виджеты, выбирайте «Редактировать виджеты» в меню колонки."
|
||||
_columns:
|
||||
main: "Основная"
|
||||
widgets: "Виджеты"
|
||||
|
@@ -944,48 +944,236 @@ _achievements:
|
||||
_types:
|
||||
_notes1:
|
||||
title: "เพียงแค่ตั้งค่า msky ของฉัน"
|
||||
description: "โพสต์โน้ตครั้งแรกของคุณ"
|
||||
flavor: "ขอให้มีช่วงเวลาที่ดีกับ Misskey นะคะ!"
|
||||
_notes10:
|
||||
title: "โน้ตบางอย่าง"
|
||||
description: "โพสต์ 10 โน้ต"
|
||||
_notes100:
|
||||
title: "โน้ตจำนวนมาก"
|
||||
description: "โพสต์ 100 โน้ต"
|
||||
_notes500:
|
||||
title: "ครอบคลุมในโน้ต"
|
||||
description: "โพสต์ 500 โน้ต"
|
||||
_notes1000:
|
||||
title: "ภูเขาแห่งโน้ต"
|
||||
description: "โพสต์ 1,000 โน้ต"
|
||||
_notes5000:
|
||||
title: "โน้ตล้น"
|
||||
description: "โพสต์ 5,000 โน้ต"
|
||||
_notes10000:
|
||||
title: "ซุปเปอร์โน้ต"
|
||||
description: "โพสต์ 10,000 โน้ต"
|
||||
_notes20000:
|
||||
title: "ต้องการ... เพิ่มเติม... โน้ต..."
|
||||
description: "โพสต์ 20,000 โน้ต"
|
||||
_notes30000:
|
||||
title: "โน้ต โน้ต โน้ต!"
|
||||
description: "โพสต์ 30,000 โน้ต"
|
||||
_notes40000:
|
||||
title: "โน้ตโรงงาน"
|
||||
description: "โพสต์ 40,000 โน้ต"
|
||||
_notes50000:
|
||||
title: "ดาวเคราะห์แห่งโน้ต"
|
||||
description: "โพสต์ 50,000 โน้ต"
|
||||
_notes60000:
|
||||
title: "โน้ตควอซาร์"
|
||||
description: "โพสต์ 60,000 โน้ต"
|
||||
_notes70000:
|
||||
title: "โน้ตหลุมดำ"
|
||||
description: "โพสต์ 70,000 โน้ต"
|
||||
_notes80000:
|
||||
title: "โน้ต กาแล็กซี่"
|
||||
description: "โพสต์ 80,000 โน้ต"
|
||||
_notes90000:
|
||||
title: "โน้ต จักรวาล"
|
||||
description: "โพสต์ 90,000 โน้ต"
|
||||
_notes100000:
|
||||
title: "ALL YOUR NOTE ARE BELONG TO US"
|
||||
description: "โพสต์ 100,000 โน้ต"
|
||||
flavor: "นายแน่ใจล่ะก็ มีอะไรพูดมาได้นะ"
|
||||
_login3:
|
||||
title: "มือใหม่ I"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 3 วัน"
|
||||
flavor: "เริ่มตั้งแต่วันนี้ เรียกฉันว่ามิสคิสต์"
|
||||
_login7:
|
||||
title: "มือใหม่ II"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 7 วัน"
|
||||
flavor: "รู้สึกเหมือนคุณได้แขวนของสิ่งต่างๆ หรือยังคะ?"
|
||||
_login15:
|
||||
title: "มือใหม่ III"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 15 วัน"
|
||||
_login30:
|
||||
title: "มิสคิสท์ I"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 30 วัน"
|
||||
_login60:
|
||||
title: "มิสคิสท์ II"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 60 วัน"
|
||||
_login100:
|
||||
title: "มิสคิสท์ III"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 100 วัน"
|
||||
flavor: "ความรุนแรง Misskist"
|
||||
_login200:
|
||||
title: "ลูกค้าประจำ I"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 200 วัน"
|
||||
_login300:
|
||||
title: "ลูกค้าประจำ II"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 300 วัน"
|
||||
_login400:
|
||||
title: "ลูกค้าประจำ III"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 400 วัน"
|
||||
_login500:
|
||||
title: "ผู้เชี่ยวชาญ I"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 500 วัน"
|
||||
flavor: "เพื่อนของผมนะมักจะกล่าวว่าผมนะชอบจดโน้ต"
|
||||
_login600:
|
||||
title: "ผู้เชี่ยวชาญ II"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 600 วัน"
|
||||
_login700:
|
||||
title: "ผู้เชี่ยวชาญ III"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 700 วัน"
|
||||
_login800:
|
||||
title: "ปรมาจารย์ด้านโน้ต I"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 800 วัน"
|
||||
_login900:
|
||||
title: "ปรมาจารย์ด้านโน้ต II"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 900 วัน"
|
||||
_login1000:
|
||||
title: "ปรมาจารย์ด้านโน้ต III"
|
||||
description: "เข้าสู่ระบบเป็นเวลารวม 1,000 วัน"
|
||||
flavor: "ขอบคุณที่ใช้ Misskey นะ !"
|
||||
_noteClipped1:
|
||||
title: "จะต้อง... คลิป..."
|
||||
description: "คลิปโน้ตตัวแรกของคุณ"
|
||||
_noteFavorited1:
|
||||
title: "สตาร์เกเซอร์"
|
||||
description: "ชื่นชอบโน้ตแรกของคุณ"
|
||||
_myNoteFavorited1:
|
||||
title: "แสวงหาดวงดาว"
|
||||
description: "มีคนอื่นๆที่ชื่นชอบหนึ่งในโน้ตของคุณ"
|
||||
_profileFilled:
|
||||
title: "เตรียมไว้อย่างดี"
|
||||
description: "ตั้งค่าโปรไฟล์ของคุณ"
|
||||
_markedAsCat:
|
||||
title: "ฉันเป็นแมว"
|
||||
description: "ทำเครื่องหมายบัญชีของคุณว่าเป็นแมว"
|
||||
flavor: "ฉันจะให้ชื่อคุณภายหลังนะ"
|
||||
_following1:
|
||||
title: "กำลังติดตามผู้ใช้คนแรกของคุณ"
|
||||
description: "ติดตามผู้ใช้"
|
||||
_following10:
|
||||
title: "ทำต่อไป... ทำต่อไป..."
|
||||
description: "ติดตาม 10 บัญชีผู้ใช้"
|
||||
_following50:
|
||||
title: "มีเพื่อนมากมาย"
|
||||
description: "ติดตาม 50 บัญชี"
|
||||
_following100:
|
||||
title: "เพื่อน 100 คน"
|
||||
description: "ติดตาม 100 บัญชี"
|
||||
_following300:
|
||||
title: "เพื่อนโอเวอร์โหลด"
|
||||
description: "ติดตาม 300 บัญชี"
|
||||
_followers1:
|
||||
title: "ผู้ติดตามคนแรก"
|
||||
description: "ได้รับ 1 ผู้ติดตาม"
|
||||
_followers10:
|
||||
title: "ติดตามฉัน!"
|
||||
description: "ได้รับ 10 คนผู้ติดตาม"
|
||||
_followers50:
|
||||
title: "มากันเป็นฝูง"
|
||||
description: "ได้รับ 50 ผู้ติดตาม"
|
||||
_followers100:
|
||||
title: "บุคคลที่เป็นที่นิยม"
|
||||
description: "ได้รับ 100 ผู้ติดตาม"
|
||||
_followers300:
|
||||
title: "กรุณาสร้างบรรทัดเดียวนะคะ"
|
||||
description: "ได้รับ 300 คนผู้ติดตาม"
|
||||
_followers500:
|
||||
title: "เสาสัญญาณ"
|
||||
description: "ได้รับ 500 คนผู้ติดตาม"
|
||||
_followers1000:
|
||||
title: "ผู้ทรงอิทธิพล"
|
||||
description: "ได้รับ 1,000 ผู้ติดตาม"
|
||||
_collectAchievements30:
|
||||
title: "นักสะสมความสำเร็จ"
|
||||
description: "ได้รับความสำเร็จ 30 ครั้ง"
|
||||
_viewAchievements3min:
|
||||
title: "ชอบบรรลุผลสําเร็จ"
|
||||
description: "มองดูรายการความสำเร็จของคุณเป็นเวลาอย่างน้อย 3 นาที"
|
||||
_iLoveMisskey:
|
||||
title: "ฉันรัก Misskey"
|
||||
description: "โพสต์ \"I ❤ #Misskey\""
|
||||
flavor: "ทีมผู้พัฒนา Misskey ได้ขอบคุณสำหรับการสนับสนุนของคุณ!"
|
||||
_foundTreasure:
|
||||
title: "ล่าสมบัติ"
|
||||
description: "คุณพบสมบัติที่ซ่อนอยู่"
|
||||
_client30min:
|
||||
title: "พักผ่อนสักหน่อย"
|
||||
description: "ใช้เวลา 30 นาทีบน Misskey"
|
||||
_noteDeletedWithin1min:
|
||||
title: "ไม่เป็นไร"
|
||||
description: "ลบโน้ตภายในหนึ่งนาทีหลังจากที่โพสต์"
|
||||
_postedAtLateNight:
|
||||
title: "กลางคืน"
|
||||
description: "โพสต์โน้ตตอนดึกๆ"
|
||||
flavor: "ได้เวลาเข้านอนแล้วนะ"
|
||||
_postedAt0min0sec:
|
||||
title: "นาฬิกาพูดได้"
|
||||
description: "โพสต์บนโน้ตเมื่อเวลา 00:00 น."
|
||||
flavor: "คลิก คลิก คลิก แกล๊งๆ"
|
||||
_selfQuote:
|
||||
title: "อ้างอิงตนเอง"
|
||||
description: "อ้างโน้ตย่อของคุณเอง"
|
||||
_htl20npm:
|
||||
title: "ไทม์ไลน์ไหล"
|
||||
description: "มีการทำความเร็วของไทม์ไลน์ที่บ้านของคุณเกิน 20 npm (โน้ตต่อนาที)"
|
||||
_viewInstanceChart:
|
||||
title: "วิเคราะห์"
|
||||
description: "ดูแผนภูมิอินสแตนซ์ของคุณ"
|
||||
_outputHelloWorldOnScratchpad:
|
||||
title: "หวัดดีชาวโลก!"
|
||||
description: "เอาพุต \"hello world\" ใน Scratchpad"
|
||||
_open3windows:
|
||||
title: "มัลติวินโดว์"
|
||||
description: "มีการเปิดหน้าต่างอย่างน้อย 3 หน้าต่างพร้อมกัน"
|
||||
_driveFolderCircularReference:
|
||||
title: "อ้างอิงวงจร"
|
||||
description: "พยายามสร้างโฟลเดอร์ที่ซ้อนกันแบบวนซ้ำในไดรฟ์"
|
||||
_reactWithoutRead:
|
||||
title: "คุณอ่านมันจริงๆหรือเปล่า?"
|
||||
description: "มีการโต้ตอบกับโน้ตที่มีความยาวมากกว่า 100 ตัวอักษรภายใน 3 วินาทีหลังจากที่โพสต์"
|
||||
_clickedClickHere:
|
||||
title: "คลิ๊กที่นี่"
|
||||
description: "คุณได้คลิกที่นี่"
|
||||
_justPlainLucky:
|
||||
title: "แค่ลัคกี้ธรรมดา"
|
||||
description: "มีโอกาสที่จะได้รับด้วยความน่าจะเป็นไปได้ 0.01% ทุก ๆ 10 วินาที"
|
||||
_setNameToSyuilo:
|
||||
title: "พระเจ้าคอมเพล็กซ์"
|
||||
description: "ตั้งชื่อของคุณเป็น \"syuilo\""
|
||||
_passedSinceAccountCreated1:
|
||||
title: "ครบรอบหนึ่งปี"
|
||||
description: "ผ่านไปหนึ่งปีแล้วนะตั้งแต่บัญชีของคุณถูกสร้างขึ้นมาน่ะ"
|
||||
_passedSinceAccountCreated2:
|
||||
title: "ครบรอบสองปี"
|
||||
description: "ผ่านไปสองปีแล้วนะตั้งแต่บัญชีของคุณถูกสร้างขึ้นมาน่ะ"
|
||||
_passedSinceAccountCreated3:
|
||||
title: "ครบรอบสามปี"
|
||||
description: "ผ่านไปสามปีแล้วนะตั้งแต่บัญชีของคุณถูกสร้างขึ้นมาน่ะ"
|
||||
_loggedInOnBirthday:
|
||||
title: "สุขสันต์วันเกิด"
|
||||
description: "เข้าสู่ระบบในวันเกิดของคุณ"
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "สวัสดีปีใหม่!"
|
||||
description: "เข้าสู่ระบบในวันแรกของปี"
|
||||
flavor: "อีกปีที่ยอดเยี่ยมในโอกาสนี้เลย"
|
||||
_cookieClicked:
|
||||
title: "เกมที่คุณคลิกที่คุกกี้"
|
||||
description: "คลิกคุกกี้"
|
||||
flavor: "เดี๋ยวก่อนนะ คุณอยู่ในเว็บไซต์ที่ถูกต้องแน่อย่างงั้นเหรอ?"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
description: "โพสต์ลิงก์ไปยัง Brain Diver"
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
_role:
|
||||
new: "บทบาทใหม่"
|
||||
|
@@ -529,7 +529,7 @@ state: "Стан"
|
||||
sort: "Сортування"
|
||||
ascendingOrder: "За зростанням"
|
||||
descendingOrder: "За спаданням"
|
||||
scratchpad: "Чернетка"
|
||||
scratchpad: "Scratchpad"
|
||||
scratchpadDescription: "Scratchpad надає середовище для експериментів з AiScript. Ви можете писати, виконувати його і тестувати взаємодію з Misskey."
|
||||
output: "Вихід"
|
||||
script: "Скрипт"
|
||||
@@ -688,7 +688,7 @@ pageLikesCount: "Кількість отриманих вподобань сто
|
||||
pageLikedCount: "Кількість вподобаних сторінок"
|
||||
contact: "Контакт"
|
||||
useSystemFont: "Використовувати стандартний шрифт системи"
|
||||
clips: "Добірка"
|
||||
clips: "Добірки"
|
||||
experimentalFeatures: "Експериментальні функції"
|
||||
developer: "Розробник"
|
||||
makeExplorable: "Зробіть обліковий запис видимим у розділі \"Огляд\""
|
||||
@@ -901,9 +901,10 @@ show: "Відображення"
|
||||
color: "Колір"
|
||||
achievements: "Досягнення"
|
||||
_achievements:
|
||||
earnedAt: "Відкрито"
|
||||
_types:
|
||||
_notes1:
|
||||
title: "налаштовую свій msky"
|
||||
title: "Привіт, Misskey!"
|
||||
description: "Перша нотатка"
|
||||
flavor: "Приємного часу з Misskey!"
|
||||
_notes10:
|
||||
@@ -954,35 +955,165 @@ _achievements:
|
||||
flavor: "Так багато потрібно сказати?"
|
||||
_login3:
|
||||
title: "Новачок I"
|
||||
description: "3 дні користування загально"
|
||||
flavor: "Відсьогодні називайте мене \"Місскіст\""
|
||||
_login7:
|
||||
title: "Новачок II"
|
||||
description: "7 днів користування загально"
|
||||
flavor: "Ви звикли до цього?"
|
||||
_login15:
|
||||
title: "Новачок III"
|
||||
description: "15 днів користування загально"
|
||||
_login30:
|
||||
title: "Міскієць I"
|
||||
description: "30 днів користування загально"
|
||||
_login60:
|
||||
title: "Міскієць II"
|
||||
description: "60 днів користування загально"
|
||||
_login100:
|
||||
title: "Міскієць III"
|
||||
description: "100 днів користування загально"
|
||||
flavor: "Цей юзер лютий місскіст"
|
||||
_login200:
|
||||
title: "Завсідник I"
|
||||
description: "200 днів користування загально"
|
||||
_login300:
|
||||
title: "Завсідник II"
|
||||
description: "300 днів користування загально"
|
||||
_login400:
|
||||
title: "Завсідник III"
|
||||
description: "400 днів користування загально"
|
||||
_login500:
|
||||
title: "Ветеран I"
|
||||
description: "500 днів користування загально"
|
||||
flavor: "Meine Kameraden, ich liebe sie, die Notizen."
|
||||
_login600:
|
||||
title: "Ветеран II"
|
||||
description: "600 днів користування загально"
|
||||
_login700:
|
||||
title: "Ветеран III"
|
||||
description: "700 днів користування загально"
|
||||
_login800:
|
||||
title: "Майстер нотаток I"
|
||||
description: "800 днів користування загально"
|
||||
_login900:
|
||||
title: "Майстер нотаток II"
|
||||
description: "900 днів користування загально"
|
||||
_login1000:
|
||||
title: "Майстер нотаток III"
|
||||
description: "1000 днів користування загально"
|
||||
flavor: "Дякуємо, що користуєтеся Misskey!"
|
||||
_noteClipped1:
|
||||
title: "Не можна не зберегти"
|
||||
description: "Перша нотатка у добірці"
|
||||
_noteFavorited1:
|
||||
title: "Дивитися на зірки"
|
||||
_myNoteFavorited1:
|
||||
title: "У пошуках зірок"
|
||||
_profileFilled:
|
||||
title: "Повна готовність"
|
||||
description: "Профіль заповнено"
|
||||
_markedAsCat:
|
||||
title: "Я кіт"
|
||||
description: "Позначено як акаунт кота"
|
||||
flavor: "Я дам тобі ім'я пізніше"
|
||||
_following1:
|
||||
title: "Перша підписка"
|
||||
_following10:
|
||||
title: "Продовжуй, продовжуй"
|
||||
_following50:
|
||||
title: "Багато друзів"
|
||||
description: "Кількість підписок сягнула 50"
|
||||
_following100:
|
||||
title: "100 друзів"
|
||||
description: "Кількість підписок сягнула 100"
|
||||
_following300:
|
||||
title: "Надлишок друзів"
|
||||
description: "Кількість підписок сягнула 300"
|
||||
_followers1:
|
||||
title: "Перший підписник"
|
||||
description: "З'явився перший підписник"
|
||||
_followers10:
|
||||
title: "Follow me!"
|
||||
description: "Кількість підписників досягла 10"
|
||||
_followers50:
|
||||
description: "Кількість підписників досягла 50"
|
||||
_followers100:
|
||||
title: "Популярна особа"
|
||||
description: "Кількість підписників досягла 100"
|
||||
_followers300:
|
||||
title: "Ставайте в чергу"
|
||||
description: "Кількість підписників досягла 300"
|
||||
_followers500:
|
||||
title: "Радіовежа"
|
||||
description: "Кількість підписників досягла 500"
|
||||
_followers1000:
|
||||
title: "Інфлюенсер"
|
||||
description: "Кількість підписників досягла 1000"
|
||||
_collectAchievements30:
|
||||
title: "Збирач досягнень"
|
||||
description: "Отримано 30 досягнень"
|
||||
_viewAchievements3min:
|
||||
title: "Шанувальник досягнень"
|
||||
description: "Переглядати список досягнень принаймні 3 хвилини"
|
||||
_iLoveMisskey:
|
||||
title: "I Love Misskey"
|
||||
description: "Відправлено \"I ❤ #Misskey\""
|
||||
flavor: "Дякуємо вам, що користуєтесь Misskey! – команда розробників"
|
||||
_foundTreasure:
|
||||
title: "Пошуки скарбів"
|
||||
description: "Ви знайшли прихований скарб"
|
||||
_client30min:
|
||||
title: "Коротка перерва"
|
||||
description: "З моменту запуску клієнта минуло 30 хвилин"
|
||||
_noteDeletedWithin1min:
|
||||
title: "Не зважай"
|
||||
description: "Допис видалено протягом 1 хвилини після публікації"
|
||||
_postedAtLateNight:
|
||||
title: "Нічне життя"
|
||||
description: "Відправити нотатку посеред ночі"
|
||||
flavor: "Час лягати спати"
|
||||
_postedAt0min0sec:
|
||||
title: "Сигнал часу"
|
||||
description: "Відправити нотатку о 00:00"
|
||||
_selfQuote:
|
||||
title: "Самопосилання"
|
||||
description: "Процитувати власну нотатку"
|
||||
_htl20npm:
|
||||
title: "Плинна стрічка"
|
||||
description: "Перевищити швидкість домашньої стрічки 20npm (нотаток на хвилину)"
|
||||
_viewInstanceChart:
|
||||
title: "Аналітик"
|
||||
_outputHelloWorldOnScratchpad:
|
||||
title: "Hello, world!"
|
||||
description: "Вивести \"hello world\" у Скретчпаді"
|
||||
_clickedClickHere:
|
||||
title: "Натисніть тут"
|
||||
description: "Натиснуто тут"
|
||||
_justPlainLucky:
|
||||
title: "Просто вдача"
|
||||
description: "Можна отримати з ймовірністю 0,01% кожні 10 секунд"
|
||||
_setNameToSyuilo:
|
||||
title: "Комплекс бога"
|
||||
description: "Встановлено ім'я \"syuilo\""
|
||||
_passedSinceAccountCreated1:
|
||||
title: "Перша річниця"
|
||||
description: "Минув рік з моменту створення акаунта"
|
||||
_passedSinceAccountCreated2:
|
||||
title: "Друга річниця"
|
||||
description: "Минуло 2 роки з моменту створення акаунта"
|
||||
_passedSinceAccountCreated3:
|
||||
title: "Третя річниця"
|
||||
description: "Минуло 3 роки з моменту створення акаунта"
|
||||
_loggedInOnBirthday:
|
||||
title: "З Днем народження!"
|
||||
description: "Увійти у свій день народження"
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "З Новим роком!"
|
||||
description: "Увійшли в перший день року"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
description: "Відправити посилання на \"Brain Diver\""
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
_role:
|
||||
priority: "Пріоритет"
|
||||
@@ -1256,7 +1387,7 @@ _tutorial:
|
||||
step3_1: "Ви успішно налаштували свій обліковий запис?"
|
||||
step3_2: "Наступним кроком є написання нотатки. Це можна зробити, натиснувши зображення олівця на екрані."
|
||||
step3_3: "Після написання вмісту ви можете опублікувати його, натиснувши кнопку у верхньому правому куті форми."
|
||||
step3_4: "Не знаєте що написати? Спробуйте \"налаштовую свій msky\"!"
|
||||
step3_4: "Не знаєте що написати? Спробуйте \"Привіт, Misskey!\""
|
||||
step4_1: "Ви розмістили свій перший запис?"
|
||||
step4_2: "Ура! Ваш перший запис відображається на вашій стрічці подій."
|
||||
step5_1: "Настав час оживити вашу стрічку подій підписавшись на інших користувачів."
|
||||
@@ -1520,6 +1651,7 @@ _notification:
|
||||
youReceivedFollowRequest: "Ви отримали запит на підписку"
|
||||
yourFollowRequestAccepted: "Запит на підписку прийнято"
|
||||
youWereInvitedToGroup: "Запрошення до групи"
|
||||
achievementEarned: "Досягнення відкрито"
|
||||
_types:
|
||||
all: "Все"
|
||||
follow: "Підписки"
|
||||
|
@@ -995,52 +995,158 @@ _achievements:
|
||||
_login3:
|
||||
title: "初学者 I"
|
||||
description: "连续登录3天"
|
||||
flavor: "今天开始我就是Misskist!"
|
||||
_login7:
|
||||
title: "初学者 II"
|
||||
description: "连续登录7天"
|
||||
flavor: "您开始习惯了吗?"
|
||||
_login15:
|
||||
title: "初学者 III"
|
||||
description: "连续登录15天"
|
||||
_login30:
|
||||
title: "Misskist Ⅰ"
|
||||
description: "连续登录30天"
|
||||
_login60:
|
||||
title: "Misskist Ⅱ"
|
||||
description: "连续登录60天"
|
||||
_login100:
|
||||
title: "Misskist Ⅲ"
|
||||
description: "总登入100天"
|
||||
flavor: "那个用户,是Misskist喔"
|
||||
_login200:
|
||||
title: "定期联系Ⅰ"
|
||||
description: "总登录天数200天"
|
||||
_login300:
|
||||
title: "定期联系Ⅱ"
|
||||
description: "总登录天数300天"
|
||||
_login400:
|
||||
title: "定期联系Ⅲ"
|
||||
description: "总登录天数400天"
|
||||
_login500:
|
||||
description: "总登录天数500天"
|
||||
flavor: "诸君,我喜欢贴文"
|
||||
_login600:
|
||||
description: "总登录天数600天"
|
||||
_login700:
|
||||
description: "总登录天数700天"
|
||||
_login800:
|
||||
description: "总登录天数800天"
|
||||
_login900:
|
||||
description: "总登录天数900天"
|
||||
_login1000:
|
||||
description: "总登录天数1000天"
|
||||
flavor: "感谢您使用Misskey!"
|
||||
_noteClipped1:
|
||||
title: "忍不住要收藏到便签"
|
||||
description: "第一次将贴文贴进便签"
|
||||
_noteFavorited1:
|
||||
title: "观星者"
|
||||
description: "第一次将帖子加入收藏"
|
||||
_myNoteFavorited1:
|
||||
title: "想要星星"
|
||||
description: "自己的帖子被其他人加入收藏了"
|
||||
_profileFilled:
|
||||
title: "整装待发"
|
||||
description: "设置了个人资料"
|
||||
_markedAsCat:
|
||||
title: "我是猫"
|
||||
description: "将账户设定为一只猫"
|
||||
flavor: "还没有名字"
|
||||
_following1:
|
||||
title: "首次关注"
|
||||
description: "第一次关注别人"
|
||||
_following10:
|
||||
title: "关注,跟随"
|
||||
description: "关注超过10人"
|
||||
_following50:
|
||||
title: "我的朋友很多"
|
||||
description: "关注超过50人"
|
||||
_following100:
|
||||
title: "我的朋友很多"
|
||||
description: "关注超过100人"
|
||||
_following300:
|
||||
title: "朋友成群"
|
||||
description: "关注数超过300"
|
||||
_followers1:
|
||||
title: "最初的关注者"
|
||||
description: "第一次被关注"
|
||||
_followers10:
|
||||
title: "关注我吧!"
|
||||
description: "关注者超过10人"
|
||||
_followers50:
|
||||
title: "三五成群"
|
||||
description: "关注者超过50人"
|
||||
_followers100:
|
||||
title: "胜友如云"
|
||||
description: "关注者超过100人"
|
||||
_followers300:
|
||||
title: "排列成行"
|
||||
description: "关注者超过300人"
|
||||
_followers500:
|
||||
title: "信号塔"
|
||||
description: "关注者超过500人"
|
||||
_collectAchievements30:
|
||||
title: "成就收藏家"
|
||||
description: "获得超过30个成就"
|
||||
_viewAchievements3min:
|
||||
title: "成就爱好者"
|
||||
description: "盯着成就看三分钟"
|
||||
_iLoveMisskey:
|
||||
title: "I Love Misskey"
|
||||
description: "发布\"I ❤ #Misskey\"帖子"
|
||||
flavor: "感谢您使用 Misskey ! by 开发团队"
|
||||
_foundTreasure:
|
||||
description: "发现了隐藏的宝藏"
|
||||
_client30min:
|
||||
title: "休息一下!"
|
||||
description: "启动客户端超过30分钟"
|
||||
_noteDeletedWithin1min:
|
||||
title: "无话可说"
|
||||
description: "发帖后一分钟内就将其删除"
|
||||
_postedAtLateNight:
|
||||
title: "夜行者"
|
||||
title: "夜猫子"
|
||||
description: "深夜发布帖子"
|
||||
flavor: "差不多该去睡了喔。"
|
||||
_postedAt0min0sec:
|
||||
title: "报时"
|
||||
description: "在0点发布一篇帖子"
|
||||
flavor: "嘣 嘣 嘣 Biu——!"
|
||||
_selfQuote:
|
||||
title: "自我提及"
|
||||
description: "引用了自己的帖子"
|
||||
_htl20npm:
|
||||
title: "流动的时间线"
|
||||
description: "在首页时间线的流速超过20npm"
|
||||
_viewInstanceChart:
|
||||
title: "分析师"
|
||||
description: "查看了实例信息中的图表"
|
||||
_outputHelloWorldOnScratchpad:
|
||||
title: "Hello, world!"
|
||||
_open3windows:
|
||||
title: "多窗口"
|
||||
description: "打开了三个或更多的窗口"
|
||||
_driveFolderCircularReference:
|
||||
title: "循环引用"
|
||||
_reactWithoutRead:
|
||||
title: "有好好读过吗?"
|
||||
description: "在含有100字以上的帖子被发出三秒内做出回应"
|
||||
_clickedClickHere:
|
||||
title: "点这里"
|
||||
description: "点了这里"
|
||||
_justPlainLucky:
|
||||
title: "超高校级的幸运"
|
||||
description: "每10秒有0.01的概率自动获得"
|
||||
_setNameToSyuilo:
|
||||
title: "像神一样呐"
|
||||
description: "将名称设定为syuilo"
|
||||
_passedSinceAccountCreated1:
|
||||
title: "一周年"
|
||||
description: "账户创建时间超过1年"
|
||||
_passedSinceAccountCreated2:
|
||||
title: "二周年"
|
||||
description: "账户创建时间超过2年"
|
||||
_passedSinceAccountCreated3:
|
||||
title: "三周年"
|
||||
description: "账户创建时间超过3年"
|
||||
_loggedInOnBirthday:
|
||||
title: "生日快乐"
|
||||
@@ -1048,6 +1154,15 @@ _achievements:
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "恭贺新禧"
|
||||
description: "在元旦登入"
|
||||
flavor: "今年也请对本实例多多指教!"
|
||||
_cookieClicked:
|
||||
title: "点击饼干小游戏"
|
||||
description: "点击了可疑的饼干"
|
||||
flavor: "是不是软件有问题?"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
description: "发布了包含Brain Diver链接的帖子"
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
_role:
|
||||
new: "创建角色"
|
||||
edit: "编辑角色"
|
||||
@@ -1566,7 +1681,7 @@ _profile:
|
||||
name: "昵称"
|
||||
username: "用户名"
|
||||
description: "个人简介"
|
||||
youCanIncludeHashtags: "您可以包含一个哈希标签。"
|
||||
youCanIncludeHashtags: "你可以在个人简介中包含一个#标签。"
|
||||
metadata: "附加信息"
|
||||
metadataEdit: "附加信息编辑"
|
||||
metadataDescription: "最多可以在个人资料中以表格形式显示四条其他信息。"
|
||||
|
@@ -240,7 +240,7 @@ removeAreYouSure: "確定要刪掉「{x}」嗎?"
|
||||
deleteAreYouSure: "確定要刪掉「{x}」嗎?"
|
||||
resetAreYouSure: "確定要重設嗎?"
|
||||
saved: "已儲存"
|
||||
messaging: "傳送訊息"
|
||||
messaging: "聊天"
|
||||
upload: "上傳"
|
||||
keepOriginalUploading: "保留原圖"
|
||||
keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時生成一張用於web發布的圖片。"
|
||||
@@ -331,10 +331,10 @@ registration: "註冊"
|
||||
enableRegistration: "開啟新使用者註冊"
|
||||
invite: "邀請"
|
||||
driveCapacityPerLocalAccount: "每個本地用戶的雲端空間大小"
|
||||
driveCapacityPerRemoteAccount: "每個非本地用戶的雲端容量"
|
||||
driveCapacityPerRemoteAccount: "每個非本地用戶的雲端空間大小"
|
||||
inMb: "以Mbps為單位"
|
||||
iconUrl: "圖像URL"
|
||||
bannerUrl: "橫幅圖像URL"
|
||||
iconUrl: "圖標URL"
|
||||
bannerUrl: "橫幅圖片URL"
|
||||
backgroundImageUrl: "背景圖片的來源網址 "
|
||||
basicInfo: "基本資訊"
|
||||
pinnedUsers: "置頂用戶"
|
||||
@@ -373,8 +373,8 @@ connectedTo: "您的帳戶已連接到以下社交帳戶"
|
||||
notesAndReplies: "貼文與回覆"
|
||||
withFiles: "附件"
|
||||
silence: "禁言"
|
||||
silenceConfirm: "確定要禁言此用戶嗎?"
|
||||
unsilence: "解除禁言"
|
||||
silenceConfirm: "確定要靜音此使用者嗎?"
|
||||
unsilence: "解除靜音"
|
||||
unsilenceConfirm: "確定要解除禁言嗎?"
|
||||
popularUsers: "熱門使用者"
|
||||
recentlyUpdatedUsers: "最近發文的使用者"
|
||||
@@ -383,13 +383,13 @@ recentlyDiscoveredUsers: "最近發現的使用者"
|
||||
exploreUsersCount: "有{count}個使用者"
|
||||
exploreFediverse: "探索聯邦世界"
|
||||
popularTags: "熱門標籤"
|
||||
userList: "清單"
|
||||
about: "資訊"
|
||||
userList: "使用者清單"
|
||||
about: "關於"
|
||||
aboutMisskey: "關於 Misskey"
|
||||
administrator: "管理員"
|
||||
token: "權杖"
|
||||
twoStepAuthentication: "兩階段驗證"
|
||||
moderator: "監察員"
|
||||
moderator: "審核員"
|
||||
moderation: "監察"
|
||||
nUsersMentioned: "提到了{n}"
|
||||
securityKey: "安全金鑰"
|
||||
@@ -421,7 +421,7 @@ invites: "邀請"
|
||||
groupName: "群組名稱"
|
||||
members: "成員"
|
||||
transfer: "轉讓"
|
||||
messagingWithUser: "傳送訊息給其他使用者"
|
||||
messagingWithUser: "與其他使用者聊天"
|
||||
messagingWithGroup: "發送訊息至群組"
|
||||
title: "標題"
|
||||
text: "文字"
|
||||
@@ -473,7 +473,7 @@ createAccount: "建立帳戶"
|
||||
existingAccount: "現有帳戶"
|
||||
regenerate: "再生"
|
||||
fontSize: "字體大小"
|
||||
noFollowRequests: "沒有要求跟隨您的申請"
|
||||
noFollowRequests: "沒有跟隨您的請求"
|
||||
openImageInNewTab: "於新分頁中開啟圖片"
|
||||
dashboard: "儀表板"
|
||||
local: "本地"
|
||||
@@ -530,8 +530,8 @@ installedDate: "安裝時間"
|
||||
lastUsedDate: "最後上線日期"
|
||||
state: "狀態"
|
||||
sort: "排序"
|
||||
ascendingOrder: "昇冪"
|
||||
descendingOrder: "降冪"
|
||||
ascendingOrder: "遞增"
|
||||
descendingOrder: "遞減"
|
||||
scratchpad: "暫存記憶體"
|
||||
scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Misskey互動的结果。"
|
||||
output: "輸出"
|
||||
@@ -995,24 +995,24 @@ _achievements:
|
||||
_login3:
|
||||
title: "初學者Ⅰ"
|
||||
description: "總登入天數為3天"
|
||||
flavor: "從今天開始,我就是Misskeyist"
|
||||
flavor: "從今天開始,我就是Misskist"
|
||||
_login7:
|
||||
title: "初學者ⅠⅠ"
|
||||
description: "總登入天數為7天"
|
||||
flavor: "您開始習慣了嗎?"
|
||||
_login15:
|
||||
title: "初學者III"
|
||||
title: "初學者ⅠⅠⅠ"
|
||||
description: "總登入天數為15天"
|
||||
_login30:
|
||||
title: "Misskeyist Ⅰ"
|
||||
title: "Misskist Ⅰ"
|
||||
description: "總登入天數為30天"
|
||||
_login60:
|
||||
title: "Misskeyist ⅠⅠ"
|
||||
title: "Misskist ⅠⅠ"
|
||||
description: "總登入天數為60天"
|
||||
_login100:
|
||||
title: "Misskeyist ⅠⅠⅠ"
|
||||
title: "Misskist ⅠⅠⅠ"
|
||||
description: "總登入天數為100天"
|
||||
flavor: "辣個 Misskeyist 用戶"
|
||||
flavor: "辣個 Misskist 用戶"
|
||||
_login200:
|
||||
title: "普通Ⅰ"
|
||||
description: "總登入天數為200天"
|
||||
@@ -1089,7 +1089,7 @@ _achievements:
|
||||
title: "請排成一排"
|
||||
description: "跟隨者超過300人了"
|
||||
_followers500:
|
||||
title: "基站"
|
||||
title: "基地台"
|
||||
description: "超過500名追隨者了"
|
||||
_followers1000:
|
||||
title: "影響者"
|
||||
@@ -1111,7 +1111,7 @@ _achievements:
|
||||
title: "休息一下"
|
||||
description: "用戶端啟動已超過30分鐘"
|
||||
_noteDeletedWithin1min:
|
||||
title: "現在沒有"
|
||||
title: "現在沒有了"
|
||||
description: "發文後1分鐘內刪文"
|
||||
_postedAtLateNight:
|
||||
title: "夜行性"
|
||||
|
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "13.2.1",
|
||||
"version": "13.3.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -38,8 +38,8 @@
|
||||
"cleanall": "pnpm clean-all"
|
||||
},
|
||||
"resolutions": {
|
||||
"chokidar": "^3.5.3",
|
||||
"lodash": "^4.17.21"
|
||||
"chokidar": "3.5.3",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"execa": "5.1.1",
|
||||
@@ -49,19 +49,19 @@
|
||||
"gulp-replace": "1.1.4",
|
||||
"gulp-terser": "2.1.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"typescript": "4.9.4"
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.49.0",
|
||||
"@typescript-eslint/parser": "5.49.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.50.0",
|
||||
"@typescript-eslint/parser": "5.50.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.3.0",
|
||||
"eslint": "^8.32.0",
|
||||
"cypress": "12.5.1",
|
||||
"eslint": "8.33.0",
|
||||
"start-server-and-test": "1.15.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "^4.2.0"
|
||||
"@tensorflow/tfjs-core": "4.2.0"
|
||||
}
|
||||
}
|
||||
|
29
packages/backend/migration/1675404035646-cleanup.js
Normal file
29
packages/backend/migration/1675404035646-cleanup.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export class cleanup1675404035646 {
|
||||
name = 'cleanup1675404035646'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTwitterIntegration"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableGithubIntegration"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableDiscordIntegration"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "twitterConsumerKey"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "twitterConsumerSecret"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "githubClientId"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "githubClientSecret"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "discordClientId"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "discordClientSecret"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "integrations"`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "integrations" jsonb NOT NULL DEFAULT '{}'`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "discordClientSecret" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "discordClientId" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "githubClientSecret" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "githubClientId" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "twitterConsumerSecret" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "twitterConsumerKey" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableDiscordIntegration" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableGithubIntegration" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableTwitterIntegration" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
}
|
@@ -19,27 +19,27 @@
|
||||
"test-and-coverage": "pnpm jest-and-coverage"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs": "^4.2.0",
|
||||
"@tensorflow/tfjs": "4.2.0",
|
||||
"@tensorflow/tfjs-node": "4.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^4.11.0",
|
||||
"@bull-board/fastify": "^4.11.0",
|
||||
"@bull-board/ui": "^4.11.0",
|
||||
"@bull-board/api": "4.11.0",
|
||||
"@bull-board/fastify": "4.11.0",
|
||||
"@bull-board/ui": "4.11.0",
|
||||
"@discordapp/twemoji": "14.0.2",
|
||||
"@fastify/accepts": "4.1.0",
|
||||
"@fastify/cookie": "^8.3.0",
|
||||
"@fastify/cookie": "8.3.0",
|
||||
"@fastify/cors": "8.2.0",
|
||||
"@fastify/http-proxy": "^8.4.0",
|
||||
"@fastify/http-proxy": "8.4.0",
|
||||
"@fastify/multipart": "7.4.0",
|
||||
"@fastify/static": "6.6.1",
|
||||
"@fastify/view": "7.4.0",
|
||||
"@nestjs/common": "9.2.1",
|
||||
"@nestjs/core": "9.2.1",
|
||||
"@nestjs/testing": "9.2.1",
|
||||
"@fastify/static": "6.8.0",
|
||||
"@fastify/view": "7.4.1",
|
||||
"@nestjs/common": "9.3.1",
|
||||
"@nestjs/core": "9.3.1",
|
||||
"@nestjs/testing": "9.3.1",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "10.0.2",
|
||||
"accepts": "^1.3.8",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
"autwh": "0.1.0",
|
||||
@@ -62,11 +62,11 @@
|
||||
"feed": "4.2.2",
|
||||
"file-type": "18.2.0",
|
||||
"fluent-ffmpeg": "2.1.2",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "4.0.0",
|
||||
"got": "12.5.3",
|
||||
"hpagent": "1.2.0",
|
||||
"ioredis": "4.28.5",
|
||||
"ip-cidr": "3.0.11",
|
||||
"ip-cidr": "3.1.0",
|
||||
"is-svg": "4.3.2",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsdom": "21.1.0",
|
||||
@@ -75,15 +75,16 @@
|
||||
"jsrsasign": "10.6.1",
|
||||
"mfm-js": "0.23.3",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "0.0.14",
|
||||
"misskey-js": "0.0.15",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nested-property": "4.0.0",
|
||||
"nodemailer": "6.9.0",
|
||||
"node-fetch": "3.3.0",
|
||||
"nodemailer": "6.9.1",
|
||||
"nsfwjs": "2.4.2",
|
||||
"oauth": "^0.10.0",
|
||||
"oauth": "0.10.0",
|
||||
"os-utils": "0.0.14",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.8.0",
|
||||
"pg": "8.9.0",
|
||||
"private-ip": "3.0.0",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
@@ -101,25 +102,23 @@
|
||||
"rss-parser": "3.12.0",
|
||||
"rxjs": "7.8.0",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.8.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sanitize-html": "2.9.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"semver": "7.3.8",
|
||||
"sharp": "0.31.3",
|
||||
"speakeasy": "2.0.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "2.7.0",
|
||||
"syslog-pro": "git+https://github.com/misskey-dev/SyslogPro#0.2.9-misskey.2",
|
||||
"systeminformation": "5.17.3",
|
||||
"systeminformation": "5.17.8",
|
||||
"tinycolor2": "1.5.2",
|
||||
"tmp": "0.2.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.11",
|
||||
"typescript": "4.9.4",
|
||||
"typescript": "4.9.5",
|
||||
"ulid": "2.3.0",
|
||||
"undici": "^5.16.0",
|
||||
"unzipper": "0.10.11",
|
||||
"uuid": "9.0.0",
|
||||
"vary": "1.1.2",
|
||||
@@ -129,25 +128,26 @@
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redocly/openapi-core": "1.0.0-beta.120",
|
||||
"@swc/cli": "^0.1.59",
|
||||
"@swc/core": "1.3.27",
|
||||
"@jest/globals": "29.4.1",
|
||||
"@redocly/openapi-core": "1.0.0-beta.123",
|
||||
"@swc/cli": "0.1.61",
|
||||
"@swc/core": "1.3.32",
|
||||
"@swc/jest": "0.2.24",
|
||||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.1",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/bull": "4.10.0",
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/color-convert": "^2.0.0",
|
||||
"@types/content-disposition": "^0.5.5",
|
||||
"@types/color-convert": "2.0.0",
|
||||
"@types/content-disposition": "0.5.5",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/fluent-ffmpeg": "2.1.20",
|
||||
"@types/ioredis": "4.28.10",
|
||||
"@types/jest": "29.2.6",
|
||||
"@types/jest": "29.4.0",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jsdom": "20.0.1",
|
||||
"@types/jsonld": "1.5.8",
|
||||
"@types/jsrsasign": "10.5.4",
|
||||
"@types/jsrsasign": "10.5.5",
|
||||
"@types/mime-types": "2.1.1",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
@@ -166,7 +166,6 @@
|
||||
"@types/sharp": "0.31.1",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/speakeasy": "2.0.7",
|
||||
"@types/syslog-pro": "^1.0.0",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/unzipper": "0.10.5",
|
||||
@@ -175,14 +174,13 @@
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.49.0",
|
||||
"@typescript-eslint/parser": "5.49.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.50.0",
|
||||
"@typescript-eslint/parser": "5.50.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.32.0",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"execa": "6.1.0",
|
||||
"jest": "29.3.1",
|
||||
"jest-mock": "^29.3.1",
|
||||
"node-fetch": "3.3.0"
|
||||
"jest": "29.4.1",
|
||||
"jest-mock": "29.4.1"
|
||||
}
|
||||
}
|
||||
|
@@ -65,11 +65,6 @@ export type Source = {
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
syslog: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
|
||||
@@ -113,7 +108,7 @@ const path = process.env.NODE_ENV === 'test'
|
||||
|
||||
export function loadConfig() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json')
|
||||
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
|
||||
const clientManifest = clientManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/init.ts': { file: 'src/init.ts' } };
|
||||
|
@@ -77,10 +77,16 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'antennaCreated':
|
||||
this.antennas.push(body);
|
||||
this.antennas.push({
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
});
|
||||
break;
|
||||
case 'antennaUpdated':
|
||||
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = body;
|
||||
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = {
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
};
|
||||
break;
|
||||
case 'antennaDeleted':
|
||||
this.antennas = this.antennas.filter(a => a.id !== body.id);
|
||||
|
@@ -21,18 +21,13 @@ export class CaptchaService {
|
||||
response,
|
||||
});
|
||||
|
||||
const res = await this.httpRequestService.fetch(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
body: params,
|
||||
const res = await this.httpRequestService.send(url, {
|
||||
method: 'POST',
|
||||
body: params.toString(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
{
|
||||
noOkError: true,
|
||||
}
|
||||
).catch(err => {
|
||||
throw `${err.message ?? err}`;
|
||||
});
|
||||
}, { throwErrorWhenResponseNotOk: false });
|
||||
|
||||
if (!res.ok) {
|
||||
throw `${res.status}`;
|
||||
|
@@ -62,7 +62,6 @@ import PerUserNotesChart from './chart/charts/per-user-notes.js';
|
||||
import PerUserPvChart from './chart/charts/per-user-pv.js';
|
||||
import DriveChart from './chart/charts/drive.js';
|
||||
import PerUserReactionsChart from './chart/charts/per-user-reactions.js';
|
||||
import HashtagChart from './chart/charts/hashtag.js';
|
||||
import PerUserFollowingChart from './chart/charts/per-user-following.js';
|
||||
import PerUserDriveChart from './chart/charts/per-user-drive.js';
|
||||
import ApRequestChart from './chart/charts/ap-request.js';
|
||||
@@ -187,7 +186,6 @@ const $PerUserNotesChart: Provider = { provide: 'PerUserNotesChart', useExisting
|
||||
const $PerUserPvChart: Provider = { provide: 'PerUserPvChart', useExisting: PerUserPvChart };
|
||||
const $DriveChart: Provider = { provide: 'DriveChart', useExisting: DriveChart };
|
||||
const $PerUserReactionsChart: Provider = { provide: 'PerUserReactionsChart', useExisting: PerUserReactionsChart };
|
||||
const $HashtagChart: Provider = { provide: 'HashtagChart', useExisting: HashtagChart };
|
||||
const $PerUserFollowingChart: Provider = { provide: 'PerUserFollowingChart', useExisting: PerUserFollowingChart };
|
||||
const $PerUserDriveChart: Provider = { provide: 'PerUserDriveChart', useExisting: PerUserDriveChart };
|
||||
const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRequestChart };
|
||||
@@ -315,7 +313,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
PerUserPvChart,
|
||||
DriveChart,
|
||||
PerUserReactionsChart,
|
||||
HashtagChart,
|
||||
PerUserFollowingChart,
|
||||
PerUserDriveChart,
|
||||
ApRequestChart,
|
||||
@@ -437,7 +434,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$PerUserPvChart,
|
||||
$DriveChart,
|
||||
$PerUserReactionsChart,
|
||||
$HashtagChart,
|
||||
$PerUserFollowingChart,
|
||||
$PerUserDriveChart,
|
||||
$ApRequestChart,
|
||||
@@ -559,7 +555,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
PerUserPvChart,
|
||||
DriveChart,
|
||||
PerUserReactionsChart,
|
||||
HashtagChart,
|
||||
PerUserFollowingChart,
|
||||
PerUserDriveChart,
|
||||
ApRequestChart,
|
||||
@@ -680,7 +675,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$PerUserPvChart,
|
||||
$DriveChart,
|
||||
$PerUserReactionsChart,
|
||||
$HashtagChart,
|
||||
$PerUserFollowingChart,
|
||||
$PerUserDriveChart,
|
||||
$ApRequestChart,
|
||||
|
@@ -2,22 +2,39 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import type { EmojisRepository, Note } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
private cache: Cache<Emoji | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private reactionService: ReactionService,
|
||||
) {
|
||||
this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -40,8 +57,135 @@ export class CustomEmojiService {
|
||||
type: data.driveFile.webpublicType ?? data.driveFile.type,
|
||||
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
if (data.host == null) {
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiAdded', {
|
||||
emoji: await this.emojiEntityService.pack(emoji.id),
|
||||
});
|
||||
}
|
||||
|
||||
return emoji;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
||||
// クエリに使うホスト
|
||||
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
||||
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
||||
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
||||
: (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
|
||||
|
||||
host = this.utilityService.toPunyNullable(host);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private parseEmojiStr(emojiName: string, noteUserHost: string | null) {
|
||||
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
|
||||
if (!match) return { name: null, host: null };
|
||||
|
||||
const name = match[1];
|
||||
|
||||
// ホスト正規化
|
||||
const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost));
|
||||
|
||||
return { name, host };
|
||||
}
|
||||
|
||||
/**
|
||||
* 添付用(リモート)カスタム絵文字URLを解決する
|
||||
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
|
||||
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
|
||||
* @returns URL, nullは未マッチを意味する
|
||||
*/
|
||||
@bindThis
|
||||
public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise<string | null> {
|
||||
const { name, host } = this.parseEmojiStr(emojiName, noteUserHost);
|
||||
if (name == null) return null;
|
||||
if (host == null) return null;
|
||||
|
||||
const queryOrNull = async () => (await this.emojisRepository.findOneBy({
|
||||
name,
|
||||
host: host ?? IsNull(),
|
||||
})) ?? null;
|
||||
|
||||
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
|
||||
|
||||
if (emoji == null) return null;
|
||||
|
||||
const isLocal = emoji.host == null;
|
||||
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
const url = isLocal
|
||||
? emojiUrl
|
||||
: this.config.proxyRemoteFiles
|
||||
? `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`
|
||||
: emojiUrl;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数の添付用(リモート)カスタム絵文字URLを解決する (キャシュ付き, 存在しないものは結果から除外される)
|
||||
*/
|
||||
@bindThis
|
||||
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> {
|
||||
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost)));
|
||||
const res = {} as any;
|
||||
for (let i = 0; i < emojiNames.length; i++) {
|
||||
if (emojis[i] != null) {
|
||||
res[emojiNames[i]] = emojis[i];
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public aggregateNoteEmojis(notes: Note[]) {
|
||||
let emojis: { name: string | null; host: string | null; }[] = [];
|
||||
for (const note of notes) {
|
||||
emojis = emojis.concat(note.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.userHost)));
|
||||
if (note.renote) {
|
||||
emojis = emojis.concat(note.renote.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
|
||||
if (note.renote.user) {
|
||||
emojis = emojis.concat(note.renote.user.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.renote!.userHost)));
|
||||
}
|
||||
}
|
||||
const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
|
||||
emojis = emojis.concat(customReactions);
|
||||
if (note.user) {
|
||||
emojis = emojis.concat(note.user.emojis
|
||||
.map(e => this.parseEmojiStr(e, note.userHost)));
|
||||
}
|
||||
}
|
||||
return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
|
||||
*/
|
||||
@bindThis
|
||||
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
||||
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const emojisQuery: any[] = [];
|
||||
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
||||
for (const host of hosts) {
|
||||
if (host == null) continue;
|
||||
emojisQuery.push({
|
||||
name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)),
|
||||
host: host,
|
||||
});
|
||||
}
|
||||
const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({
|
||||
where: emojisQuery,
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
}) : [];
|
||||
for (const emoji of _emojis) {
|
||||
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,10 +5,10 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
import PrivateIp from 'private-ip';
|
||||
import chalk from 'chalk';
|
||||
import { buildConnector } from 'undici';
|
||||
import got, * as Got from 'got';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { HttpRequestService, UndiciFetcher } from '@/core/HttpRequestService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
@@ -20,7 +20,6 @@ import { bindThis } from '@/decorators.js';
|
||||
@Injectable()
|
||||
export class DownloadService {
|
||||
private logger: Logger;
|
||||
private undiciFetcher: UndiciFetcher;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -30,21 +29,6 @@ export class DownloadService {
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('download');
|
||||
|
||||
this.undiciFetcher = this.httpRequestService.createFetcher({
|
||||
connect: process.env.NODE_ENV === 'development' ?
|
||||
this.httpRequestService.clientDefaults.connect
|
||||
:
|
||||
this.httpRequestService.getConnectorWithIpCheck(
|
||||
buildConnector({
|
||||
...this.httpRequestService.clientDefaults.connect,
|
||||
}),
|
||||
(ip) => !this.isPrivateIp(ip),
|
||||
),
|
||||
bodyTimeout: 30 * 1000,
|
||||
}, {
|
||||
connect: this.httpRequestService.clientDefaults.connect,
|
||||
}, this.logger);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -55,14 +39,60 @@ export class DownloadService {
|
||||
const operationTimeout = 60 * 1000;
|
||||
const maxSize = this.config.maxFileSize ?? 262144000;
|
||||
|
||||
const response = await this.undiciFetcher.fetch(url);
|
||||
const req = got.stream(url, {
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
timeout: {
|
||||
lookup: timeout,
|
||||
connect: timeout,
|
||||
secureConnect: timeout,
|
||||
socket: timeout, // read timeout
|
||||
response: timeout,
|
||||
send: timeout,
|
||||
request: operationTimeout, // whole operation timeout
|
||||
},
|
||||
agent: {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
},
|
||||
http2: false, // default
|
||||
retry: {
|
||||
limit: 0,
|
||||
},
|
||||
}).on('response', (res: Got.Response) => {
|
||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
||||
if (this.isPrivateIp(res.ip)) {
|
||||
this.logger.warn(`Blocked address: ${res.ip}`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
if (response.body === null) {
|
||||
throw new StatusError('No body', 400, 'No body');
|
||||
const contentLength = res.headers['content-length'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
if (size > maxSize) {
|
||||
this.logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||
if (progress.transferred > maxSize) {
|
||||
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(req, fs.createWriteStream(path));
|
||||
} catch (e) {
|
||||
if (e instanceof Got.HTTPError) {
|
||||
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline(stream.Readable.fromWeb(response.body), fs.createWriteStream(path));
|
||||
|
||||
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import fetch from 'node-fetch';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import type { InstancesRepository } from '@/models/index.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
@@ -190,7 +191,9 @@ export class FetchInstanceMetadataService {
|
||||
|
||||
const faviconUrl = url + '/favicon.ico';
|
||||
|
||||
const favicon = await this.httpRequestService.fetch(faviconUrl, {}, { noOkError: true });
|
||||
const favicon = await this.httpRequestService.send(faviconUrl, {
|
||||
method: 'HEAD',
|
||||
}, { throwErrorWhenResponseNotOk: false });
|
||||
|
||||
if (favicon.ok) {
|
||||
return faviconUrl;
|
||||
|
@@ -4,7 +4,6 @@ import type { User } from '@/models/entities/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { Hashtag } from '@/models/entities/Hashtag.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import type { HashtagsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -20,7 +19,6 @@ export class HashtagService {
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private hashtagChart: HashtagChart,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -143,9 +141,5 @@ export class HashtagService {
|
||||
} as Hashtag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUserAttached) {
|
||||
this.hashtagChart.update(tag, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,284 +1,67 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { LookupFunction } from 'node:net';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as undici from 'undici';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
// true to allow, false to deny
|
||||
export type IpChecker = (ip: string) => boolean;
|
||||
|
||||
/*
|
||||
* Child class to create and save Agent for fetch.
|
||||
* You should construct this when you want
|
||||
* to change timeout, size limit, socket connect function, etc.
|
||||
*/
|
||||
export class UndiciFetcher {
|
||||
/**
|
||||
* Get http non-proxy agent (undici)
|
||||
*/
|
||||
public nonProxiedAgent: undici.Agent;
|
||||
|
||||
/**
|
||||
* Get http proxy or non-proxy agent (undici)
|
||||
*/
|
||||
public agent: undici.ProxyAgent | undici.Agent;
|
||||
|
||||
private proxyBypassHosts: string[];
|
||||
private userAgent: string | undefined;
|
||||
|
||||
private logger: Logger | undefined;
|
||||
|
||||
constructor(
|
||||
args: {
|
||||
agentOptions: undici.Agent.Options;
|
||||
proxy?: {
|
||||
uri: string;
|
||||
options?: undici.Agent.Options; // Override of agentOptions
|
||||
},
|
||||
proxyBypassHosts?: string[];
|
||||
userAgent?: string;
|
||||
},
|
||||
logger?: Logger,
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.logger?.debug('UndiciFetcher constructor', args);
|
||||
|
||||
this.proxyBypassHosts = args.proxyBypassHosts ?? [];
|
||||
this.userAgent = args.userAgent;
|
||||
|
||||
this.nonProxiedAgent = new undici.Agent({
|
||||
...args.agentOptions,
|
||||
connect: (process.env.NODE_ENV !== 'production' && typeof args.agentOptions.connect !== 'function')
|
||||
? (options, cb) => {
|
||||
// Custom connector for debug
|
||||
undici.buildConnector(args.agentOptions.connect as undici.buildConnector.BuildOptions)(options, (err, socket) => {
|
||||
this.logger?.debug('Socket connector called', socket);
|
||||
if (err) {
|
||||
this.logger?.debug('Socket error', err);
|
||||
cb(new Error(`Error while socket connecting\n${err}`), null);
|
||||
return;
|
||||
}
|
||||
this.logger?.debug(`Socket connected: port ${socket.localPort} => remote ${socket.remoteAddress}`);
|
||||
cb(null, socket);
|
||||
});
|
||||
} : args.agentOptions.connect,
|
||||
});
|
||||
|
||||
this.agent = args.proxy
|
||||
? new undici.ProxyAgent({
|
||||
...args.agentOptions,
|
||||
...args.proxy.options,
|
||||
|
||||
uri: args.proxy.uri,
|
||||
|
||||
connect: (process.env.NODE_ENV !== 'production' && typeof (args.proxy.options?.connect ?? args.agentOptions.connect) !== 'function')
|
||||
? (options, cb) => {
|
||||
// Custom connector for debug
|
||||
undici.buildConnector((args.proxy?.options?.connect ?? args.agentOptions.connect) as undici.buildConnector.BuildOptions)(options, (err, socket) => {
|
||||
this.logger?.debug('Socket connector called (secure)', socket);
|
||||
if (err) {
|
||||
this.logger?.debug('Socket error', err);
|
||||
cb(new Error(`Error while socket connecting\n${err}`), null);
|
||||
return;
|
||||
}
|
||||
this.logger?.debug(`Socket connected (secure): port ${socket.localPort} => remote ${socket.remoteAddress}`);
|
||||
cb(null, socket);
|
||||
});
|
||||
} : (args.proxy.options?.connect ?? args.agentOptions.connect),
|
||||
})
|
||||
: this.nonProxiedAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
*/
|
||||
@bindThis
|
||||
public getAgentByUrl(url: URL, bypassProxy = false): undici.Agent | undici.ProxyAgent {
|
||||
if (bypassProxy || this.proxyBypassHosts.includes(url.hostname)) {
|
||||
return this.nonProxiedAgent;
|
||||
} else {
|
||||
return this.agent;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(
|
||||
url: string | URL,
|
||||
options: undici.RequestInit = {},
|
||||
privateOptions: { noOkError?: boolean; bypassProxy?: boolean; } = { noOkError: false, bypassProxy: false },
|
||||
): Promise<undici.Response> {
|
||||
const res = await undici.fetch(url, {
|
||||
dispatcher: this.getAgentByUrl(new URL(url), privateOptions.bypassProxy),
|
||||
...options,
|
||||
headers: {
|
||||
'User-Agent': this.userAgent ?? '',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
}).catch((err) => {
|
||||
this.logger?.error(`fetch error to ${typeof url === 'string' ? url : url.href}`, err);
|
||||
throw new StatusError('Resource Unreachable', 500, 'Resource Unreachable');
|
||||
});
|
||||
if (!res.ok && !privateOptions.noOkError) {
|
||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async request(
|
||||
url: string | URL,
|
||||
options: { dispatcher?: undici.Dispatcher } & Omit<undici.Dispatcher.RequestOptions, 'origin' | 'path' | 'method'> & Partial<Pick<undici.Dispatcher.RequestOptions, 'method'>> = {},
|
||||
privateOptions: { noOkError?: boolean; bypassProxy?: boolean; } = { noOkError: false, bypassProxy: false },
|
||||
): Promise<undici.Dispatcher.ResponseData> {
|
||||
const res = await undici.request(url, {
|
||||
dispatcher: this.getAgentByUrl(new URL(url), privateOptions.bypassProxy),
|
||||
...options,
|
||||
headers: {
|
||||
'user-agent': this.userAgent ?? '',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
}).catch((err) => {
|
||||
this.logger?.error(`fetch error to ${typeof url === 'string' ? url : url.href}`, err);
|
||||
throw new StatusError('Resource Unreachable', 500, 'Resource Unreachable');
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
throw new StatusError(`${res.statusCode}`, res.statusCode, '');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getJson<T extends unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
||||
const { body } = await this.request(
|
||||
url,
|
||||
{
|
||||
headers: Object.assign({
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
return await body.json() as T;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
||||
const { body } = await this.request(
|
||||
url,
|
||||
{
|
||||
headers: Object.assign({
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
return await body.text();
|
||||
}
|
||||
}
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
|
||||
@Injectable()
|
||||
export class HttpRequestService {
|
||||
public defaultFetcher: UndiciFetcher;
|
||||
public fetch: UndiciFetcher['fetch'];
|
||||
public request: UndiciFetcher['request'];
|
||||
public getHtml: UndiciFetcher['getHtml'];
|
||||
public defaultJsonFetcher: UndiciFetcher;
|
||||
public getJson: UndiciFetcher['getJson'];
|
||||
|
||||
//#region for old http/https, only used in S3Service
|
||||
// http non-proxy agent
|
||||
/**
|
||||
* Get http non-proxy agent
|
||||
*/
|
||||
private http: http.Agent;
|
||||
|
||||
// https non-proxy agent
|
||||
/**
|
||||
* Get https non-proxy agent
|
||||
*/
|
||||
private https: https.Agent;
|
||||
|
||||
// http proxy or non-proxy agent
|
||||
/**
|
||||
* Get http proxy or non-proxy agent
|
||||
*/
|
||||
public httpAgent: http.Agent;
|
||||
|
||||
// https proxy or non-proxy agent
|
||||
/**
|
||||
* Get https proxy or non-proxy agent
|
||||
*/
|
||||
public httpsAgent: https.Agent;
|
||||
//#endregion
|
||||
|
||||
public readonly dnsCache: CacheableLookup;
|
||||
public readonly clientDefaults: undici.Agent.Options;
|
||||
private maxSockets: number;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('http-request');
|
||||
|
||||
this.dnsCache = new CacheableLookup({
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
errorTtl: 30, // 30secs
|
||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.clientDefaults = {
|
||||
keepAliveTimeout: 30 * 1000,
|
||||
keepAliveMaxTimeout: 10 * 60 * 1000,
|
||||
keepAliveTimeoutThreshold: 1 * 1000,
|
||||
strictContentLength: true,
|
||||
headersTimeout: 10 * 1000,
|
||||
bodyTimeout: 10 * 1000,
|
||||
maxHeaderSize: 16364, // default
|
||||
maxResponseSize: 10 * 1024 * 1024,
|
||||
maxRedirections: 3,
|
||||
connect: {
|
||||
timeout: 10 * 1000, // コネクションが確立するまでのタイムアウト
|
||||
maxCachedSessions: 300, // TLSセッションのキャッシュ数 https://github.com/nodejs/undici/blob/v5.14.0/lib/core/connect.js#L80
|
||||
lookup: this.dnsCache.lookup as LookupFunction, // https://github.com/nodejs/undici/blob/v5.14.0/lib/core/connect.js#L98
|
||||
},
|
||||
};
|
||||
|
||||
this.maxSockets = Math.max(64, ((this.config.deliverJobConcurrency ?? 128) / (this.config.clusterLimit ?? 1)));
|
||||
|
||||
this.defaultFetcher = this.createFetcher({}, {}, this.logger);
|
||||
|
||||
this.fetch = this.defaultFetcher.fetch;
|
||||
this.request = this.defaultFetcher.request;
|
||||
this.getHtml = this.defaultFetcher.getHtml;
|
||||
|
||||
this.defaultJsonFetcher = this.createFetcher({
|
||||
maxResponseSize: 1024 * 256,
|
||||
}, {}, this.logger);
|
||||
|
||||
this.getJson = this.defaultJsonFetcher.getJson;
|
||||
|
||||
//#region for old http/https, only used in S3Service
|
||||
|
||||
this.http = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: this.dnsCache.lookup,
|
||||
lookup: cache.lookup,
|
||||
} as http.AgentOptions);
|
||||
|
||||
this.https = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: this.dnsCache.lookup,
|
||||
lookup: cache.lookup,
|
||||
} as https.AgentOptions);
|
||||
|
||||
|
||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||
|
||||
this.httpAgent = config.proxy
|
||||
? new HttpProxyAgent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
maxSockets: this.maxSockets,
|
||||
maxSockets,
|
||||
maxFreeSockets: 256,
|
||||
scheduling: 'lifo',
|
||||
proxy: config.proxy,
|
||||
@@ -289,47 +72,21 @@ export class HttpRequestService {
|
||||
? new HttpsProxyAgent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
maxSockets: this.maxSockets,
|
||||
maxSockets,
|
||||
maxFreeSockets: 256,
|
||||
scheduling: 'lifo',
|
||||
proxy: config.proxy,
|
||||
})
|
||||
: this.https;
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getStandardUndiciFetcherOption(opts: undici.Agent.Options = {}, proxyOpts: undici.Agent.Options = {}) {
|
||||
return {
|
||||
agentOptions: {
|
||||
...this.clientDefaults,
|
||||
...opts,
|
||||
},
|
||||
...(this.config.proxy ? {
|
||||
proxy: {
|
||||
uri: this.config.proxy,
|
||||
options: {
|
||||
connections: this.maxSockets,
|
||||
...proxyOpts,
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
userAgent: this.config.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createFetcher(opts: undici.Agent.Options = {}, proxyOpts: undici.Agent.Options = {}, logger: Logger) {
|
||||
return new UndiciFetcher(this.getStandardUndiciFetcherOption(opts, proxyOpts), logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get http agent by URL
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
*/
|
||||
@bindThis
|
||||
public getHttpAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
||||
if (bypassProxy || (this.config.proxyBypassHosts || []).includes(url.hostname)) {
|
||||
return url.protocol === 'http:' ? this.http : this.https;
|
||||
} else {
|
||||
@@ -337,37 +94,67 @@ export class HttpRequestService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check ip
|
||||
*/
|
||||
@bindThis
|
||||
public getConnectorWithIpCheck(connector: undici.buildConnector.connector, checkIp: IpChecker): undici.buildConnector.connectorAsync {
|
||||
return (options, cb) => {
|
||||
connector(options, (err, socket) => {
|
||||
this.logger.debug('Socket connector (with ip checker) called', socket);
|
||||
if (err) {
|
||||
this.logger.error('Socket error', err);
|
||||
cb(new Error(`Error while socket connecting\n${err}`), null);
|
||||
return;
|
||||
}
|
||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
'User-Agent': this.config.userAgent,
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
});
|
||||
|
||||
if (socket.remoteAddress == undefined) {
|
||||
this.logger.error('Socket error: remoteAddress is undefined');
|
||||
cb(new Error('remoteAddress is undefined (maybe socket destroyed)'), null);
|
||||
return;
|
||||
}
|
||||
return await res.json() as T;
|
||||
}
|
||||
|
||||
// allow
|
||||
if (checkIp(socket.remoteAddress)) {
|
||||
this.logger.debug(`Socket connected (ip ok): ${socket.localPort} => ${socket.remoteAddress}`);
|
||||
cb(null, socket);
|
||||
return;
|
||||
}
|
||||
@bindThis
|
||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
'User-Agent': this.config.userAgent,
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
this.logger.error('IP is not allowed', socket);
|
||||
cb(new StatusError('IP is not allowed', 403, 'IP is not allowed'), null);
|
||||
socket.destroy();
|
||||
});
|
||||
};
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async send(url: string, args: {
|
||||
method?: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
} = {}, extra: {
|
||||
throwErrorWhenResponseNotOk: boolean;
|
||||
} = {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
}): Promise<Response> {
|
||||
const timeout = args.timeout ?? 5000;
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: args.method ?? 'GET',
|
||||
headers: args.headers,
|
||||
body: args.body,
|
||||
size: args.size ?? 10 * 1024 * 1024,
|
||||
agent: (url) => this.getAgentByUrl(url),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok && extra.throwErrorWhenResponseNotOk) {
|
||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,14 @@ export type IImage = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type IImageStream = {
|
||||
data: Readable;
|
||||
ext: string | null;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type IImageStreamable = IImage | IImageStream;
|
||||
|
||||
export const webpDefault: sharp.WebpOptions = {
|
||||
quality: 85,
|
||||
alphaQuality: 95,
|
||||
@@ -19,6 +27,7 @@ export const webpDefault: sharp.WebpOptions = {
|
||||
};
|
||||
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
@Injectable()
|
||||
export class ImageProcessingService {
|
||||
@@ -64,7 +73,7 @@ export class ImageProcessingService {
|
||||
*/
|
||||
@bindThis
|
||||
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
|
||||
return this.convertSharpToWebp(await sharp(path), width, height, options);
|
||||
return this.convertSharpToWebp(sharp(path), width, height, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -85,6 +94,27 @@ export class ImageProcessingService {
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {
|
||||
return this.convertSharpToWebpStream(sharp(path), width, height, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {
|
||||
const data = sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.webp(options)
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'webp',
|
||||
type: 'image/webp',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert to PNG
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as SyslogPro from 'syslog-pro';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
@@ -8,29 +7,14 @@ import type { KEYWORD } from 'color-convert/conversions';
|
||||
|
||||
@Injectable()
|
||||
export class LoggerService {
|
||||
private syslogClient;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
if (this.config.syslog) {
|
||||
this.syslogClient = new SyslogPro.RFC5424({
|
||||
applicationName: 'Misskey',
|
||||
timestamp: true,
|
||||
includeStructuredData: true,
|
||||
color: true,
|
||||
extendedColor: true,
|
||||
server: {
|
||||
target: config.syslog.host,
|
||||
port: config.syslog.port,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getLogger(domain: string, color?: KEYWORD | undefined, store?: boolean) {
|
||||
return new Logger(domain, color, store, this.syslogClient);
|
||||
return new Logger(domain, color, store);
|
||||
}
|
||||
}
|
||||
|
@@ -9,9 +9,9 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -107,12 +107,6 @@ export class NoteReadService {
|
||||
followingChannels: Set<Channel['id']>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const following = info?.following ? info.following : new Set<string>((await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: userId,
|
||||
},
|
||||
select: ['followeeId'],
|
||||
})).map(x => x.followeeId));
|
||||
const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: userId,
|
||||
@@ -139,7 +133,7 @@ export class NoteReadService {
|
||||
|
||||
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
|
||||
for (const antenna of myAntennas) {
|
||||
if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) {
|
||||
if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) {
|
||||
readAntennaNotes.push(note);
|
||||
}
|
||||
}
|
||||
|
@@ -91,10 +91,12 @@ export class RoleService implements OnApplicationShutdown {
|
||||
case 'roleCreated': {
|
||||
const cached = this.rolesCache.get(null);
|
||||
if (cached) {
|
||||
body.createdAt = new Date(body.createdAt);
|
||||
body.updatedAt = new Date(body.updatedAt);
|
||||
body.lastUsedAt = new Date(body.lastUsedAt);
|
||||
cached.push(body);
|
||||
cached.push({
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
updatedAt: new Date(body.updatedAt),
|
||||
lastUsedAt: new Date(body.lastUsedAt),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -103,10 +105,12 @@ export class RoleService implements OnApplicationShutdown {
|
||||
if (cached) {
|
||||
const i = cached.findIndex(x => x.id === body.id);
|
||||
if (i > -1) {
|
||||
body.createdAt = new Date(body.createdAt);
|
||||
body.updatedAt = new Date(body.updatedAt);
|
||||
body.lastUsedAt = new Date(body.lastUsedAt);
|
||||
cached[i] = body;
|
||||
cached[i] = {
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
updatedAt: new Date(body.updatedAt),
|
||||
lastUsedAt: new Date(body.lastUsedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -121,8 +125,10 @@ export class RoleService implements OnApplicationShutdown {
|
||||
case 'userRoleAssigned': {
|
||||
const cached = this.roleAssignmentByUserIdCache.get(body.userId);
|
||||
if (cached) {
|
||||
body.createdAt = new Date(body.createdAt);
|
||||
cached.push(body);
|
||||
cached.push({
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@ export class S3Service {
|
||||
? false
|
||||
: meta.objectStorageS3ForcePathStyle,
|
||||
httpOptions: {
|
||||
agent: this.httpRequestService.getHttpAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
|
||||
agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@@ -44,16 +44,25 @@ export class WebhookService implements OnApplicationShutdown {
|
||||
switch (type) {
|
||||
case 'webhookCreated':
|
||||
if (body.active) {
|
||||
this.webhooks.push(body);
|
||||
this.webhooks.push({
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'webhookUpdated':
|
||||
if (body.active) {
|
||||
const i = this.webhooks.findIndex(a => a.id === body.id);
|
||||
if (i > -1) {
|
||||
this.webhooks[i] = body;
|
||||
this.webhooks[i] = {
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
};
|
||||
} else {
|
||||
this.webhooks.push(body);
|
||||
this.webhooks.push({
|
||||
...body,
|
||||
createdAt: new Date(body.createdAt),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
|
||||
|
@@ -21,11 +21,11 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { LdSignatureService } from './LdSignatureService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import type { IActivity, IObject } from './type.js';
|
||||
import type { IIdentifier } from './models/identifier.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApRendererService {
|
||||
@@ -274,7 +274,7 @@ export class ApRendererService {
|
||||
} as any;
|
||||
|
||||
if (reaction.startsWith(':')) {
|
||||
const name = reaction.replace(/:/g, '');
|
||||
const name = reaction.replaceAll(':', '');
|
||||
const emoji = await this.emojisRepository.findOneBy({
|
||||
name,
|
||||
host: IsNull(),
|
||||
|
@@ -5,16 +5,14 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js';
|
||||
import { HttpRequestService, UndiciFetcher } from '@/core/HttpRequestService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { DevNull } from '@/misc/dev-null.js';
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
method: Dispatcher.HttpMethod;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -32,7 +30,6 @@ type PrivateKey = {
|
||||
|
||||
@Injectable()
|
||||
export class ApRequestService {
|
||||
private undiciFetcher: UndiciFetcher;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@@ -43,10 +40,8 @@ export class ApRequestService {
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||
this.undiciFetcher = this.httpRequestService.createFetcher({
|
||||
maxRedirections: 0,
|
||||
}, {}, this.logger);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -165,15 +160,11 @@ export class ApRequestService {
|
||||
},
|
||||
});
|
||||
|
||||
const response = await this.undiciFetcher.request(
|
||||
url,
|
||||
{
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
body,
|
||||
},
|
||||
);
|
||||
response.body.pipe(new DevNull());
|
||||
await this.httpRequestService.send(url, {
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,13 +186,10 @@ export class ApRequestService {
|
||||
},
|
||||
});
|
||||
|
||||
const res = await this.httpRequestService.fetch(
|
||||
url,
|
||||
{
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
},
|
||||
);
|
||||
const res = await this.httpRequestService.send(url, {
|
||||
method: req.request.method,
|
||||
headers: req.request.headers,
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { HttpRequestService, UndiciFetcher } from '@/core/HttpRequestService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -19,7 +19,6 @@ import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
private user?: ILocalUser;
|
||||
private undiciFetcher: UndiciFetcher;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@@ -39,10 +38,8 @@ export class Resolver {
|
||||
private recursionLimit = 100,
|
||||
) {
|
||||
this.history = new Set();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
this.logger = this.loggerService?.getLogger('ap-resolve'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||
this.undiciFetcher = this.httpRequestService.createFetcher({
|
||||
maxRedirections: 0,
|
||||
}, {}, this.logger);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -106,7 +103,7 @@ export class Resolver {
|
||||
|
||||
const object = (this.user
|
||||
? await this.apRequestService.signedGet(value, this.user) as IObject
|
||||
: await this.undiciFetcher.getJson<IObject>(value, 'application/activity+json, application/ld+json'));
|
||||
: await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject;
|
||||
|
||||
if (object == null || (
|
||||
Array.isArray(object['@context']) ?
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import jsonld from 'jsonld';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CONTEXTS } from './misc/contexts.js';
|
||||
@@ -9,7 +10,7 @@ import { CONTEXTS } from './misc/contexts.js';
|
||||
class LdSignature {
|
||||
public debug = false;
|
||||
public preLoad = true;
|
||||
public loderTimeout = 10 * 1000;
|
||||
public loderTimeout = 5000;
|
||||
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
@@ -84,7 +85,9 @@ class LdSignature {
|
||||
@bindThis
|
||||
public async normalize(data: any) {
|
||||
const customLoader = this.getLoader();
|
||||
return 42;
|
||||
return await jsonld.normalize(data, {
|
||||
documentLoader: customLoader,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -115,19 +118,12 @@ class LdSignature {
|
||||
|
||||
@bindThis
|
||||
private async fetchDocument(url: string) {
|
||||
const json = await this.httpRequestService.fetch(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/ld+json, application/json',
|
||||
},
|
||||
// TODO
|
||||
//timeout: this.loderTimeout,
|
||||
const json = await this.httpRequestService.send(url, {
|
||||
headers: {
|
||||
Accept: 'application/ld+json, application/json',
|
||||
},
|
||||
{
|
||||
noOkError: true,
|
||||
}
|
||||
).then(res => {
|
||||
timeout: this.loderTimeout,
|
||||
}, { throwErrorWhenResponseNotOk: false }).then(res => {
|
||||
if (!res.ok) {
|
||||
throw `${res.status} ${res.statusText}`;
|
||||
} else {
|
||||
|
@@ -29,6 +29,7 @@ import { UserNotePining } from '@/models/entities/UserNotePining.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
@@ -43,37 +44,6 @@ import type { IActor, IObject, IApPropertyValue } from '../type.js';
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
|
||||
const services: {
|
||||
[x: string]: (id: string, username: string) => any
|
||||
} = {
|
||||
'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }),
|
||||
'misskey:authentication:github': (id, login) => ({ id, login }),
|
||||
'misskey:authentication:discord': (id, name) => $discord(id, name),
|
||||
};
|
||||
|
||||
const $discord = (id: string, name: string) => {
|
||||
if (typeof name !== 'string') {
|
||||
name = 'unknown#0000';
|
||||
}
|
||||
const [username, discriminator] = name.split('#');
|
||||
return { id, username, discriminator };
|
||||
};
|
||||
|
||||
function addService(target: { [x: string]: any }, source: IApPropertyValue) {
|
||||
const service = services[source.name];
|
||||
|
||||
if (typeof source.value !== 'string') {
|
||||
source.value = 'unknown';
|
||||
}
|
||||
|
||||
const [id, username] = source.value.split('@');
|
||||
|
||||
if (service) {
|
||||
target[source.name.split(':')[2]] = service(id, username);
|
||||
}
|
||||
}
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApPersonService implements OnModuleInit {
|
||||
private utilityService: UtilityService;
|
||||
@@ -540,22 +510,16 @@ export class ApPersonService implements OnModuleInit {
|
||||
name: string,
|
||||
value: string
|
||||
}[] = [];
|
||||
const services: { [x: string]: any } = {};
|
||||
|
||||
if (Array.isArray(attachments)) {
|
||||
for (const attachment of attachments.filter(isPropertyValue)) {
|
||||
if (isPropertyValue(attachment.identifier)) {
|
||||
addService(services, attachment.identifier);
|
||||
} else {
|
||||
fields.push({
|
||||
name: attachment.name,
|
||||
value: this.mfmService.fromHtml(attachment.value),
|
||||
});
|
||||
}
|
||||
fields.push({
|
||||
name: attachment.name,
|
||||
value: this.mfmService.fromHtml(attachment.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, services };
|
||||
return { fields };
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -566,22 +530,22 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
this.logger.info(`Updating the featured: ${user.uri}`);
|
||||
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
|
||||
// Resolve to (Ordered)Collection Object
|
||||
const collection = await resolver.resolveCollection(user.featured);
|
||||
const collection = await _resolver.resolveCollection(user.featured);
|
||||
if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection');
|
||||
|
||||
// Resolve to Object(may be Note) arrays
|
||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
||||
const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x)));
|
||||
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
|
||||
|
||||
// Resolve and regist Notes
|
||||
const limit = promiseLimit<Note | null>(2);
|
||||
const featuredNotes = await Promise.all(items
|
||||
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
|
||||
.slice(0, 5)
|
||||
.map(item => limit(() => this.apNoteService.resolveNote(item, resolver))));
|
||||
.map(item => limit(() => this.apNoteService.resolveNote(item, _resolver))));
|
||||
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.delete(UserNotePining, { userId: user.id });
|
||||
|
@@ -10,7 +10,6 @@ import PerUserNotesChart from './charts/per-user-notes.js';
|
||||
import PerUserPvChart from './charts/per-user-pv.js';
|
||||
import DriveChart from './charts/drive.js';
|
||||
import PerUserReactionsChart from './charts/per-user-reactions.js';
|
||||
import HashtagChart from './charts/hashtag.js';
|
||||
import PerUserFollowingChart from './charts/per-user-following.js';
|
||||
import PerUserDriveChart from './charts/per-user-drive.js';
|
||||
import ApRequestChart from './charts/ap-request.js';
|
||||
@@ -31,7 +30,6 @@ export class ChartManagementService implements OnApplicationShutdown {
|
||||
private perUserPvChart: PerUserPvChart,
|
||||
private driveChart: DriveChart,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private hashtagChart: HashtagChart,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
@@ -46,7 +44,6 @@ export class ChartManagementService implements OnApplicationShutdown {
|
||||
this.perUserPvChart,
|
||||
this.driveChart,
|
||||
this.perUserReactionsChart,
|
||||
this.hashtagChart,
|
||||
this.perUserFollowingChart,
|
||||
this.perUserDriveChart,
|
||||
this.apRequestChart,
|
||||
|
@@ -1,10 +0,0 @@
|
||||
import Chart from '../../core.js';
|
||||
|
||||
export const name = 'hashtag';
|
||||
|
||||
export const schema = {
|
||||
'local.users': { uniqueIncrement: true },
|
||||
'remote.users': { uniqueIncrement: true },
|
||||
} as const;
|
||||
|
||||
export const entity = Chart.schemaToEntity(name, schema, true);
|
@@ -1,45 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import Chart from '../core.js';
|
||||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||
import { name, schema } from './entities/hashtag.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ハッシュタグに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class HashtagChart extends Chart<typeof schema> {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private userEntityService: UserEntityService,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise<void> {
|
||||
await this.commit({
|
||||
'local.users': this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
'remote.users': this.userEntityService.isLocalUser(user) ? [] : [user.id],
|
||||
}, hashtag);
|
||||
}
|
||||
}
|
@@ -11,9 +11,9 @@ import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { Repository, DataSource } from 'typeorm';
|
||||
|
||||
const columnPrefix = '___' as const;
|
||||
const uniqueTempColumnPrefix = 'unique_temp___' as const;
|
||||
const columnDot = '_' as const;
|
||||
const COLUMN_PREFIX = '___' as const;
|
||||
const UNIQUE_TEMP_COLUMN_PREFIX = 'unique_temp___' as const;
|
||||
const COLUMN_DELIMITER = '_' as const;
|
||||
|
||||
type Schema = Record<string, {
|
||||
uniqueIncrement?: boolean;
|
||||
@@ -26,14 +26,14 @@ type Schema = Record<string, {
|
||||
accumulate?: boolean;
|
||||
}>;
|
||||
|
||||
type KeyToColumnName<T extends string> = T extends `${infer R1}.${infer R2}` ? `${R1}${typeof columnDot}${KeyToColumnName<R2>}` : T;
|
||||
type KeyToColumnName<T extends string> = T extends `${infer R1}.${infer R2}` ? `${R1}${typeof COLUMN_DELIMITER}${KeyToColumnName<R2>}` : T;
|
||||
|
||||
type Columns<S extends Schema> = {
|
||||
[K in keyof S as `${typeof columnPrefix}${KeyToColumnName<string & K>}`]: number;
|
||||
[K in keyof S as `${typeof COLUMN_PREFIX}${KeyToColumnName<string & K>}`]: number;
|
||||
};
|
||||
|
||||
type TempColumnsForUnique<S extends Schema> = {
|
||||
[K in keyof S as `${typeof uniqueTempColumnPrefix}${KeyToColumnName<string & K>}`]: S[K]['uniqueIncrement'] extends true ? string[] : never;
|
||||
[K in keyof S as `${typeof UNIQUE_TEMP_COLUMN_PREFIX}${KeyToColumnName<string & K>}`]: S[K]['uniqueIncrement'] extends true ? string[] : never;
|
||||
};
|
||||
|
||||
type RawRecord<S extends Schema> = {
|
||||
@@ -138,20 +138,20 @@ export default abstract class Chart<T extends Schema> {
|
||||
private static convertSchemaToColumnDefinitions(schema: Schema): Record<string, { type: string; array?: boolean; default?: any; }> {
|
||||
const columns = {} as Record<string, { type: string; array?: boolean; default?: any; }>;
|
||||
for (const [k, v] of Object.entries(schema)) {
|
||||
const name = k.replaceAll('.', columnDot);
|
||||
const name = k.replaceAll('.', COLUMN_DELIMITER);
|
||||
const type = v.range === 'big' ? 'bigint' : v.range === 'small' ? 'smallint' : 'integer';
|
||||
if (v.uniqueIncrement) {
|
||||
columns[uniqueTempColumnPrefix + name] = {
|
||||
columns[UNIQUE_TEMP_COLUMN_PREFIX + name] = {
|
||||
type: 'varchar',
|
||||
array: true,
|
||||
default: '{}',
|
||||
};
|
||||
columns[columnPrefix + name] = {
|
||||
columns[COLUMN_PREFIX + name] = {
|
||||
type,
|
||||
default: 0,
|
||||
};
|
||||
} else {
|
||||
columns[columnPrefix + name] = {
|
||||
columns[COLUMN_PREFIX + name] = {
|
||||
type,
|
||||
default: 0,
|
||||
};
|
||||
@@ -253,8 +253,8 @@ export default abstract class Chart<T extends Schema> {
|
||||
@bindThis
|
||||
private convertRawRecord(x: RawRecord<T>): KVs<T> {
|
||||
const kvs = {} as Record<string, number>;
|
||||
for (const k of Object.keys(x).filter((k) => k.startsWith(columnPrefix)) as (keyof Columns<T>)[]) {
|
||||
kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k] as unknown as number;
|
||||
for (const k of Object.keys(x).filter((k) => k.startsWith(COLUMN_PREFIX)) as (keyof Columns<T>)[]) {
|
||||
kvs[(k as string).substr(COLUMN_PREFIX.length).split(COLUMN_DELIMITER).join('.')] = x[k] as unknown as number;
|
||||
}
|
||||
return kvs as KVs<T>;
|
||||
}
|
||||
@@ -357,8 +357,8 @@ export default abstract class Chart<T extends Schema> {
|
||||
|
||||
const columns = {} as Record<string, number | unknown[]>;
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
const name = k.replaceAll('.', columnDot);
|
||||
columns[columnPrefix + name] = v;
|
||||
const name = k.replaceAll('.', COLUMN_DELIMITER);
|
||||
columns[COLUMN_PREFIX + name] = v;
|
||||
}
|
||||
|
||||
// 新規ログ挿入
|
||||
@@ -419,13 +419,13 @@ export default abstract class Chart<T extends Schema> {
|
||||
const queryForDay: Record<keyof RawRecord<T>, number | (() => string)> = {} as any;
|
||||
for (const [k, v] of Object.entries(finalDiffs)) {
|
||||
if (typeof v === 'number') {
|
||||
const name = columnPrefix + k.replaceAll('.', columnDot) as string & keyof Columns<T>;
|
||||
const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as string & keyof Columns<T>;
|
||||
if (v > 0) queryForHour[name] = () => `"${name}" + ${v}`;
|
||||
if (v < 0) queryForHour[name] = () => `"${name}" - ${Math.abs(v)}`;
|
||||
if (v > 0) queryForDay[name] = () => `"${name}" + ${v}`;
|
||||
if (v < 0) queryForDay[name] = () => `"${name}" - ${Math.abs(v)}`;
|
||||
} else if (Array.isArray(v) && v.length > 0) { // ユニークインクリメント
|
||||
const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as string & keyof TempColumnsForUnique<T>;
|
||||
const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as string & keyof TempColumnsForUnique<T>;
|
||||
// TODO: item をSQLエスケープ
|
||||
const itemsForHour = v.filter(item => !(logHour[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`);
|
||||
const itemsForDay = v.filter(item => !(logDay[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`);
|
||||
@@ -437,8 +437,8 @@ export default abstract class Chart<T extends Schema> {
|
||||
// bake unique count
|
||||
for (const [k, v] of Object.entries(finalDiffs)) {
|
||||
if (this.schema[k].uniqueIncrement) {
|
||||
const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns<T>;
|
||||
const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>;
|
||||
const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>;
|
||||
const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>;
|
||||
queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size;
|
||||
queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size;
|
||||
}
|
||||
@@ -449,15 +449,15 @@ export default abstract class Chart<T extends Schema> {
|
||||
for (const [k, v] of Object.entries(this.schema)) {
|
||||
const intersection = v.intersection;
|
||||
if (intersection) {
|
||||
const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns<T>;
|
||||
const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>;
|
||||
const firstKey = intersection[0];
|
||||
const firstTempColumnName = uniqueTempColumnPrefix + firstKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>;
|
||||
const firstTempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + firstKey.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>;
|
||||
const firstValues = finalDiffs[firstKey] as string[] | undefined;
|
||||
const currentValuesForHour = new Set([...(firstValues ?? []), ...(logHour[firstTempColumnName] as unknown as string[])]);
|
||||
const currentValuesForDay = new Set([...(firstValues ?? []), ...(logDay[firstTempColumnName] as unknown as string[])]);
|
||||
for (let i = 1; i < intersection.length; i++) {
|
||||
const targetKey = intersection[i];
|
||||
const targetTempColumnName = uniqueTempColumnPrefix + targetKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>;
|
||||
const targetTempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + targetKey.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>;
|
||||
const targetValues = finalDiffs[targetKey] as string[] | undefined;
|
||||
const targetValuesForHour = new Set([...(targetValues ?? []), ...(logHour[targetTempColumnName] as unknown as string[])]);
|
||||
const targetValuesForDay = new Set([...(targetValues ?? []), ...(logDay[targetTempColumnName] as unknown as string[])]);
|
||||
@@ -510,7 +510,7 @@ export default abstract class Chart<T extends Schema> {
|
||||
|
||||
const columns = {} as Record<keyof Columns<T>, number>;
|
||||
for (const [k, v] of Object.entries(data) as ([keyof typeof data, number])[]) {
|
||||
const name = columnPrefix + (k as string).replaceAll('.', columnDot) as keyof Columns<T>;
|
||||
const name = COLUMN_PREFIX + (k as string).replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>;
|
||||
columns[name] = v;
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@ export default abstract class Chart<T extends Schema> {
|
||||
const columns = {} as Record<keyof TempColumnsForUnique<T>, []>;
|
||||
for (const [k, v] of Object.entries(this.schema)) {
|
||||
if (v.uniqueIncrement) {
|
||||
const name = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>;
|
||||
const name = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>;
|
||||
columns[name] = [];
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ import { entity as PerUserNotesChart } from './charts/entities/per-user-notes.js
|
||||
import { entity as PerUserPvChart } from './charts/entities/per-user-pv.js';
|
||||
import { entity as DriveChart } from './charts/entities/drive.js';
|
||||
import { entity as PerUserReactionsChart } from './charts/entities/per-user-reactions.js';
|
||||
import { entity as HashtagChart } from './charts/entities/hashtag.js';
|
||||
import { entity as PerUserFollowingChart } from './charts/entities/per-user-following.js';
|
||||
import { entity as PerUserDriveChart } from './charts/entities/per-user-drive.js';
|
||||
import { entity as ApRequestChart } from './charts/entities/ap-request.js';
|
||||
@@ -27,7 +26,6 @@ export const entities = [
|
||||
PerUserPvChart.hour, PerUserPvChart.day,
|
||||
DriveChart.hour, DriveChart.day,
|
||||
PerUserReactionsChart.hour, PerUserReactionsChart.day,
|
||||
HashtagChart.hour, HashtagChart.day,
|
||||
PerUserFollowingChart.hour, PerUserFollowingChart.day,
|
||||
PerUserDriveChart.hour, PerUserDriveChart.day,
|
||||
ApRequestChart.hour, ApRequestChart.day,
|
||||
|
@@ -22,8 +22,10 @@ export class EmojiEntityService {
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: Emoji['id'] | Emoji,
|
||||
opts: { omitHost?: boolean; omitId?: boolean; withUrl?: boolean; } = {},
|
||||
opts: { omitHost?: boolean; omitId?: boolean; withUrl?: boolean; } = { omitHost: true, omitId: true, withUrl: true },
|
||||
): Promise<Packed<'Emoji'>> {
|
||||
opts = { omitHost: true, omitId: true, withUrl: true, ...opts }
|
||||
|
||||
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return {
|
||||
|
@@ -282,7 +282,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||
: await this.channelsRepository.findOneBy({ id: note.channelId })
|
||||
: null;
|
||||
|
||||
const reactionEmojiNames = Object.keys(note.reactions).filter(x => x.startsWith(':')).map(x => this.reactionService.decodeReaction(x).reaction).map(x => x.replace(/:/g, ''));
|
||||
const reactionEmojiNames = Object.keys(note.reactions)
|
||||
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
|
||||
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
|
||||
|
||||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
@@ -299,6 +301,8 @@ export class NoteEntityService implements OnModuleInit {
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
reactions: this.reactionService.convertLegacyReactions(note.reactions),
|
||||
reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host),
|
||||
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
|
||||
tags: note.tags.length > 0 ? note.tags : undefined,
|
||||
fileIds: note.fileIds,
|
||||
files: this.driveFileEntityService.packMany(note.fileIds),
|
||||
@@ -384,6 +388,8 @@ export class NoteEntityService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
|
||||
|
||||
return await Promise.all(notes.map(n => this.pack(n, me, {
|
||||
...options,
|
||||
_hint_: {
|
||||
|
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository } from '@/models/index.js';
|
||||
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Notification } from '@/models/entities/Notification.js';
|
||||
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
@@ -146,6 +146,8 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null);
|
||||
}
|
||||
|
||||
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
|
||||
|
||||
return await Promise.all(notifications.map(x => this.pack(x, {
|
||||
_hintForEachNotes_: {
|
||||
myReactions: myReactionsMap,
|
||||
|
@@ -413,6 +413,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
faviconUrl: instance.faviconUrl,
|
||||
themeColor: instance.themeColor,
|
||||
} : undefined) : undefined,
|
||||
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
|
||||
onlineStatus: this.getOnlineStatus(user),
|
||||
|
||||
...(opts.detail ? {
|
||||
@@ -488,7 +489,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
|
||||
hasUnreadNotification: this.getHasUnreadNotification(user.id),
|
||||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
integrations: profile!.integrations,
|
||||
mutedWords: profile!.mutedWords,
|
||||
mutedInstances: profile!.mutedInstances,
|
||||
mutingNotificationTypes: profile!.mutingNotificationTypes,
|
||||
@@ -496,10 +496,10 @@ export class UserEntityService implements OnModuleInit {
|
||||
showTimelineReplies: user.showTimelineReplies ?? falsy,
|
||||
achievements: profile!.achievements,
|
||||
loggedInDays: profile!.loggedInDates.length,
|
||||
policies: this.roleService.getUserPolicies(user.id),
|
||||
} : {}),
|
||||
|
||||
...(opts.includeSecrets ? {
|
||||
policies: this.roleService.getUserPolicies(user.id),
|
||||
email: profile!.email,
|
||||
emailVerified: profile!.emailVerified,
|
||||
securityKeysList: profile!.twoFactorEnabled
|
||||
|
@@ -17,15 +17,13 @@ export default class Logger {
|
||||
private context: Context;
|
||||
private parentLogger: Logger | null = null;
|
||||
private store: boolean;
|
||||
private syslogClient: any | null = null;
|
||||
|
||||
constructor(context: string, color?: KEYWORD, store = true, syslogClient = null) {
|
||||
constructor(context: string, color?: KEYWORD, store = true) {
|
||||
this.context = {
|
||||
name: context,
|
||||
color: color,
|
||||
};
|
||||
this.store = store;
|
||||
this.syslogClient = syslogClient;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -68,20 +66,7 @@ export default class Logger {
|
||||
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
|
||||
|
||||
console.log(important ? chalk.bold(log) : log);
|
||||
|
||||
if (store) {
|
||||
if (this.syslogClient) {
|
||||
const send =
|
||||
level === 'error' ? this.syslogClient.error :
|
||||
level === 'warning' ? this.syslogClient.warning :
|
||||
level === 'success' ? this.syslogClient.info :
|
||||
level === 'debug' ? this.syslogClient.info :
|
||||
level === 'info' ? this.syslogClient.info :
|
||||
null as never;
|
||||
|
||||
send.bind(this.syslogClient)(message).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (level === 'error' && data) console.log(data);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@@ -4,7 +4,7 @@ import { unique } from '@/misc/prelude/array.js';
|
||||
export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] {
|
||||
const emojiNodes = mfm.extract(nodes, (node) => {
|
||||
return (node.type === 'emojiCode' && node.props.name.length <= 100);
|
||||
});
|
||||
}) as mfm.MfmEmojiCode[];
|
||||
|
||||
return unique(emojiNodes.map(x => x.props.name));
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import * as mfm from 'mfm-js';
|
||||
import { unique } from '@/misc/prelude/array.js';
|
||||
|
||||
export function extractHashtags(nodes: mfm.MfmNode[]): string[] {
|
||||
const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag');
|
||||
const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag') as mfm.MfmHashtag[];
|
||||
const hashtags = unique(hashtagNodes.map(x => x.props.hashtag));
|
||||
|
||||
return hashtags;
|
||||
|
@@ -1,9 +1,14 @@
|
||||
import IPCIDR from 'ip-cidr';
|
||||
|
||||
export function getIpHash(ip: string) {
|
||||
// because a single person may control many IPv6 addresses,
|
||||
// only a /64 subnet prefix of any IP will be taken into account.
|
||||
// (this means for IPv4 the entire address is used)
|
||||
const prefix = IPCIDR.createAddress(ip).mask(64);
|
||||
return 'ip-' + BigInt('0b' + prefix).toString(36);
|
||||
try {
|
||||
// because a single person may control many IPv6 addresses,
|
||||
// only a /64 subnet prefix of any IP will be taken into account.
|
||||
// (this means for IPv4 the entire address is used)
|
||||
const prefix = IPCIDR.createAddress(ip).mask(64);
|
||||
return 'ip-' + BigInt('0b' + prefix).toString(36);
|
||||
} catch (e) {
|
||||
const prefix = IPCIDR.createAddress(ip.replace(/:[0-9]+$/, '')).mask(64);
|
||||
return 'ip-' + BigInt('0b' + prefix).toString(36);
|
||||
}
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
export function nyaize(text: string): string {
|
||||
return text
|
||||
// ja-JP
|
||||
.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ')
|
||||
.replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ')
|
||||
// en-US
|
||||
.replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya')
|
||||
.replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan')
|
||||
.replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan')
|
||||
// ko-KR
|
||||
.replace(/[나-낳]/g, match => String.fromCharCode(
|
||||
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0)
|
||||
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
|
||||
))
|
||||
.replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥')
|
||||
.replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥');
|
||||
|
@@ -132,11 +132,27 @@ type NullOrUndefined<p extends Schema, T> =
|
||||
// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
|
||||
// Get intersection from union
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
|
||||
type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
|
||||
|
||||
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
|
||||
// To get union, we use `Foo extends any ? Hoge<Foo> : never`
|
||||
type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? SchemaType<X> : never;
|
||||
type ArrayUnion<T> = T extends any ? Array<T> : never;
|
||||
type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
|
||||
type ArrayUnion<T> = T extends any ? Array<T> : never;
|
||||
|
||||
type ObjectSchemaTypeDef<p extends Schema> =
|
||||
p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
|
||||
p['properties'] extends NonNullable<Obj> ?
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ?
|
||||
ObjType<p['properties'], NonNullable<p['required']>[number]> & UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>>
|
||||
:
|
||||
ObjType<p['properties'], NonNullable<p['required']>[number]>
|
||||
:
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>> :
|
||||
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
|
||||
any
|
||||
|
||||
type ObjectSchemaType<p extends Schema> = NullOrUndefined<p, ObjectSchemaTypeDef<p>>;
|
||||
|
||||
export type SchemaTypeDef<p extends Schema> =
|
||||
p['type'] extends 'null' ? null :
|
||||
@@ -149,13 +165,7 @@ export type SchemaTypeDef<p extends Schema> =
|
||||
string
|
||||
) :
|
||||
p['type'] extends 'boolean' ? boolean :
|
||||
p['type'] extends 'object' ? (
|
||||
p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
|
||||
p['properties'] extends NonNullable<Obj> ? ObjType<p['properties'], NonNullable<p['required']>[number]> :
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & Partial<UnionToIntersection<UnionSchemaType<p['anyOf']>>> :
|
||||
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
|
||||
any
|
||||
) :
|
||||
p['type'] extends 'object' ? ObjectSchemaTypeDef<p> :
|
||||
p['type'] extends 'array' ? (
|
||||
p['items'] extends OfSchema ? (
|
||||
p['items']['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<NonNullable<p['items']['anyOf']>>[] :
|
||||
@@ -166,6 +176,7 @@ export type SchemaTypeDef<p extends Schema> =
|
||||
p['items'] extends NonNullable<Schema> ? SchemaTypeDef<p['items']>[] :
|
||||
any[]
|
||||
) :
|
||||
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :
|
||||
p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
|
||||
any;
|
||||
|
||||
|
@@ -279,57 +279,6 @@ export class Meta {
|
||||
})
|
||||
public swPrivateKey: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableTwitterIntegration: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
public twitterConsumerKey: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
public twitterConsumerSecret: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableGithubIntegration: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
public githubClientId: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
public githubClientSecret: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableDiscordIntegration: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
public discordClientId: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
})
|
||||
public discordClientSecret: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
|
@@ -184,11 +184,6 @@ export class UserProfile {
|
||||
@JoinColumn()
|
||||
public pinnedPage: Page | null;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: {},
|
||||
})
|
||||
public integrations: Record<string, any>;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false, select: false,
|
||||
|
@@ -323,10 +323,6 @@ export const packedMeDetailedOnlySchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
integrations: {
|
||||
type: 'object',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
mutedWords: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
|
@@ -57,8 +57,15 @@ export class AggregateRetentionProcessorService {
|
||||
usersCount: targetUserIds.length,
|
||||
});
|
||||
|
||||
// 今日活動したユーザーを全て取得
|
||||
const activeUsers = await this.usersRepository.findBy({
|
||||
host: IsNull(),
|
||||
lastActiveDate: MoreThan(new Date(Date.now() - (1000 * 60 * 60 * 24))),
|
||||
});
|
||||
const activeUsersIds = activeUsers.map(u => u.id);
|
||||
|
||||
for (const record of pastRecords) {
|
||||
const retention = record.userIds.filter(id => targetUserIds.includes(id)).length;
|
||||
const retention = record.userIds.filter(id => activeUsersIds.includes(id)).length;
|
||||
|
||||
const data = deepClone(record.data);
|
||||
data[dateKey] = retention;
|
||||
|
@@ -12,7 +12,6 @@ import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import PerUserPvChart from '@/core/chart/charts/per-user-pv.js';
|
||||
import DriveChart from '@/core/chart/charts/drive.js';
|
||||
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
||||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
@@ -37,7 +36,6 @@ export class CleanChartsProcessorService {
|
||||
private perUserPvChart: PerUserPvChart,
|
||||
private driveChart: DriveChart,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private hashtagChart: HashtagChart,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
@@ -61,7 +59,6 @@ export class CleanChartsProcessorService {
|
||||
this.perUserPvChart.clean(),
|
||||
this.driveChart.clean(),
|
||||
this.perUserReactionsChart.clean(),
|
||||
this.hashtagChart.clean(),
|
||||
this.perUserFollowingChart.clean(),
|
||||
this.perUserDriveChart.clean(),
|
||||
this.apRequestChart.clean(),
|
||||
|
@@ -11,13 +11,12 @@ import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import DriveChart from '@/core/chart/charts/drive.js';
|
||||
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
||||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class ResyncChartsProcessorService {
|
||||
@@ -35,7 +34,6 @@ export class ResyncChartsProcessorService {
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private driveChart: DriveChart,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private hashtagChart: HashtagChart,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
|
@@ -12,7 +12,6 @@ import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import PerUserPvChart from '@/core/chart/charts/per-user-pv.js';
|
||||
import DriveChart from '@/core/chart/charts/drive.js';
|
||||
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
||||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
@@ -37,7 +36,6 @@ export class TickChartsProcessorService {
|
||||
private perUserPvChart: PerUserPvChart,
|
||||
private driveChart: DriveChart,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private hashtagChart: HashtagChart,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
@@ -61,7 +59,6 @@ export class TickChartsProcessorService {
|
||||
this.perUserPvChart.tick(false),
|
||||
this.driveChart.tick(false),
|
||||
this.perUserReactionsChart.tick(false),
|
||||
this.hashtagChart.tick(false),
|
||||
this.perUserFollowingChart.tick(false),
|
||||
this.perUserDriveChart.tick(false),
|
||||
this.apRequestChart.tick(false),
|
||||
|
@@ -6,10 +6,10 @@ import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { WebhookDeliverJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookDeliverProcessorService {
|
||||
@@ -33,26 +33,23 @@ export class WebhookDeliverProcessorService {
|
||||
try {
|
||||
this.logger.debug(`delivering ${job.data.webhookId}`);
|
||||
|
||||
const res = await this.httpRequestService.fetch(
|
||||
job.data.to,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'Misskey-Hooks',
|
||||
'X-Misskey-Host': this.config.host,
|
||||
'X-Misskey-Hook-Id': job.data.webhookId,
|
||||
'X-Misskey-Hook-Secret': job.data.secret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hookId: job.data.webhookId,
|
||||
userId: job.data.userId,
|
||||
eventId: job.data.eventId,
|
||||
createdAt: job.data.createdAt,
|
||||
type: job.data.type,
|
||||
body: job.data.content,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const res = await this.httpRequestService.send(job.data.to, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'Misskey-Hooks',
|
||||
'X-Misskey-Host': this.config.host,
|
||||
'X-Misskey-Hook-Id': job.data.webhookId,
|
||||
'X-Misskey-Hook-Secret': job.data.secret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hookId: job.data.webhookId,
|
||||
userId: job.data.userId,
|
||||
eventId: job.data.eventId,
|
||||
createdAt: job.data.createdAt,
|
||||
type: job.data.type,
|
||||
body: job.data.content,
|
||||
}),
|
||||
});
|
||||
|
||||
this.webhooksRepository.update({ id: job.data.webhookId }, {
|
||||
latestSentAt: new Date(),
|
||||
|
@@ -5,14 +5,14 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import rename from 'rename';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import type { DriveFile, DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js';
|
||||
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
|
||||
import { InternalStorageService } from '@/core/InternalStorageService.js';
|
||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||
@@ -20,6 +20,8 @@ import { FileInfoService } from '@/core/FileInfoService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
@@ -57,7 +59,7 @@ export class FileServerService {
|
||||
reply.header('Cache-Control', 'max-age=300');
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@bindThis
|
||||
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.addHook('onRequest', (request, reply, done) => {
|
||||
@@ -70,23 +72,309 @@ export class FileServerService {
|
||||
serve: false,
|
||||
});
|
||||
|
||||
fastify.get('/app-default.jpg', (request, reply) => {
|
||||
fastify.get('/files/app-default.jpg', (request, reply) => {
|
||||
const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
|
||||
reply.header('Content-Type', 'image/jpeg');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
return reply.send(file);
|
||||
});
|
||||
|
||||
fastify.get<{ Params: { key: string; } }>('/:key', async (request, reply) => await this.sendDriveFile(request, reply));
|
||||
fastify.get<{ Params: { key: string; } }>('/:key/*', async (request, reply) => await this.sendDriveFile(request, reply));
|
||||
fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => {
|
||||
return await this.sendDriveFile(request, reply)
|
||||
.catch(err => this.errorHandler(request, reply, err));
|
||||
});
|
||||
fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => {
|
||||
return await this.sendDriveFile(request, reply)
|
||||
.catch(err => this.errorHandler(request, reply, err));
|
||||
});
|
||||
|
||||
fastify.get<{
|
||||
Params: { url: string; };
|
||||
Querystring: { url?: string; };
|
||||
}>('/proxy/:url*', async (request, reply) => {
|
||||
return await this.proxyHandler(request, reply)
|
||||
.catch(err => this.errorHandler(request, reply, err));
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) {
|
||||
this.logger.error(`${err}`);
|
||||
|
||||
reply.header('Cache-Control', 'max-age=300');
|
||||
|
||||
if (request.query && 'fallback' in request.query) {
|
||||
return reply.sendFile('/dummy.png', assets);
|
||||
}
|
||||
|
||||
if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {
|
||||
reply.code(err.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.code(500);
|
||||
return;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) {
|
||||
const key = request.params.key;
|
||||
const file = await this.getFileFromKey(key).then();
|
||||
|
||||
if (file === '404') {
|
||||
reply.code(404);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
return reply.sendFile('/dummy.png', assets);
|
||||
}
|
||||
|
||||
if (file === '204') {
|
||||
reply.code(204);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.state === 'remote') {
|
||||
const convertFile = async () => {
|
||||
if (file.fileRole === 'thumbnail') {
|
||||
if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(file.mime)) {
|
||||
return this.imageProcessingService.convertToWebpStream(
|
||||
file.path,
|
||||
498,
|
||||
280
|
||||
);
|
||||
} else if (file.mime.startsWith('video/')) {
|
||||
return await this.videoProcessingService.generateVideoThumbnail(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.fileRole === 'webpublic') {
|
||||
if (['image/svg+xml'].includes(file.mime)) {
|
||||
return this.imageProcessingService.convertToWebpStream(
|
||||
file.path,
|
||||
2048,
|
||||
2048,
|
||||
{ ...webpDefault, lossless: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
};
|
||||
|
||||
const image = await convertFile();
|
||||
|
||||
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
|
||||
// image.dataがstreamなら、stream終了後にcleanup
|
||||
image.data.on('end', file.cleanup);
|
||||
image.data.on('close', file.cleanup);
|
||||
} else {
|
||||
// image.dataがstreamでないなら直ちにcleanup
|
||||
file.cleanup();
|
||||
}
|
||||
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
return image.data;
|
||||
}
|
||||
|
||||
if (file.fileRole !== 'original') {
|
||||
const filename = rename(file.file.name, {
|
||||
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
|
||||
extname: file.ext ? `.${file.ext}` : undefined,
|
||||
}).toString();
|
||||
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', filename));
|
||||
return fs.createReadStream(file.path);
|
||||
} else {
|
||||
const stream = fs.createReadStream(file.path);
|
||||
stream.on('error', this.commonReadableHandlerGenerator(reply));
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', file.file.name));
|
||||
return stream;
|
||||
}
|
||||
} catch (e) {
|
||||
if ('cleanup' in file) file.cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) {
|
||||
const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
|
||||
|
||||
if (typeof url !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const file = await this.getStreamAndTypeFromUrl(url);
|
||||
if (file === '404') {
|
||||
reply.code(404);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
return reply.sendFile('/dummy.png', assets);
|
||||
}
|
||||
|
||||
if (file === '204') {
|
||||
reply.code(204);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
|
||||
const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
|
||||
|
||||
let image: IImageStreamable | null = null;
|
||||
if ('emoji' in request.query && isConvertibleImage) {
|
||||
if (!isAnimationConvertibleImage && !('static' in request.query)) {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
} else {
|
||||
const data = sharp(file.path, { animated: !('static' in request.query) })
|
||||
.resize({
|
||||
height: 128,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp(webpDefault);
|
||||
|
||||
image = {
|
||||
data,
|
||||
ext: 'webp',
|
||||
type: 'image/webp',
|
||||
};
|
||||
}
|
||||
} else if ('static' in request.query && isConvertibleImage) {
|
||||
image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280);
|
||||
} else if ('preview' in request.query && isConvertibleImage) {
|
||||
image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200);
|
||||
} else if ('badge' in request.query) {
|
||||
if (!isConvertibleImage) {
|
||||
// 画像でないなら404でお茶を濁す
|
||||
throw new StatusError('Unexpected mime', 404);
|
||||
}
|
||||
|
||||
const mask = sharp(file.path)
|
||||
.resize(96, 96, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: false,
|
||||
})
|
||||
.greyscale()
|
||||
.normalise()
|
||||
.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
|
||||
.flatten({ background: '#000' })
|
||||
.toColorspace('b-w');
|
||||
|
||||
const stats = await mask.clone().stats();
|
||||
|
||||
if (stats.entropy < 0.1) {
|
||||
// エントロピーがあまりない場合は404にする
|
||||
throw new StatusError('Skip to provide badge', 404);
|
||||
}
|
||||
|
||||
const data = sharp({
|
||||
create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
||||
})
|
||||
.pipelineColorspace('b-w')
|
||||
.boolean(await mask.png().toBuffer(), 'eor');
|
||||
|
||||
image = {
|
||||
data: await data.png().toBuffer(),
|
||||
ext: 'png',
|
||||
type: 'image/png',
|
||||
};
|
||||
} else if (file.mime === 'image/svg+xml') {
|
||||
image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048);
|
||||
} else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) {
|
||||
throw new StatusError('Rejected type', 403, 'Rejected type');
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
}
|
||||
|
||||
if ('cleanup' in file) {
|
||||
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
|
||||
// image.dataがstreamなら、stream終了後にcleanup
|
||||
image.data.on('end', file.cleanup);
|
||||
image.data.on('close', file.cleanup);
|
||||
} else {
|
||||
// image.dataがstreamでないなら直ちにcleanup
|
||||
file.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
reply.header('Content-Type', image.type);
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
return image.data;
|
||||
} catch (e) {
|
||||
if ('cleanup' in file) file.cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getStreamAndTypeFromUrl(url: string): Promise<
|
||||
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; }
|
||||
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
|
||||
| '404'
|
||||
| '204'
|
||||
> {
|
||||
if (url.startsWith(`${this.config.url}/files/`)) {
|
||||
const key = url.replace(`${this.config.url}/files/`, '').split('/').shift();
|
||||
if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key');
|
||||
|
||||
return await this.getFileFromKey(key);
|
||||
}
|
||||
|
||||
return await this.downloadAndDetectTypeFromUrl(url);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async downloadAndDetectTypeFromUrl(url: string): Promise<
|
||||
{ state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; }
|
||||
> {
|
||||
const [path, cleanup] = await createTemp();
|
||||
try {
|
||||
await this.downloadService.downloadUrl(url, path);
|
||||
|
||||
const { mime, ext } = await this.fileInfoService.detectType(path);
|
||||
|
||||
return {
|
||||
state: 'remote',
|
||||
mime, ext,
|
||||
path, cleanup,
|
||||
}
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getFileFromKey(key: string): Promise<
|
||||
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; }
|
||||
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
|
||||
| '404'
|
||||
| '204'
|
||||
> {
|
||||
// Fetch drive file
|
||||
const file = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.accessKey = :accessKey', { accessKey: key })
|
||||
@@ -94,89 +382,41 @@ export class FileServerService {
|
||||
.orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key })
|
||||
.getOne();
|
||||
|
||||
if (file == null) {
|
||||
reply.code(404);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
return reply.sendFile('/dummy.png', assets);
|
||||
}
|
||||
if (file == null) return '404';
|
||||
|
||||
const isThumbnail = file.thumbnailAccessKey === key;
|
||||
const isWebpublic = file.webpublicAccessKey === key;
|
||||
|
||||
if (!file.storedInternal) {
|
||||
if (file.isLink && file.uri) { // 期限切れリモートファイル
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
try {
|
||||
await this.downloadService.downloadUrl(file.uri, path);
|
||||
|
||||
const { mime, ext } = await this.fileInfoService.detectType(path);
|
||||
|
||||
const convertFile = async () => {
|
||||
if (isThumbnail) {
|
||||
if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(mime)) {
|
||||
return await this.imageProcessingService.convertToWebp(path, 498, 280);
|
||||
} else if (mime.startsWith('video/')) {
|
||||
return await this.videoProcessingService.generateVideoThumbnail(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (isWebpublic) {
|
||||
if (['image/svg+xml'].includes(mime)) {
|
||||
return await this.imageProcessingService.convertToPng(path, 2048, 2048);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: fs.readFileSync(path),
|
||||
ext,
|
||||
type: mime,
|
||||
};
|
||||
};
|
||||
|
||||
const image = await convertFile();
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
return image.data;
|
||||
} catch (err) {
|
||||
this.logger.error(`${err}`);
|
||||
|
||||
if (err instanceof StatusError && err.isClientError) {
|
||||
reply.code(err.statusCode);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
} else {
|
||||
reply.code(500);
|
||||
reply.header('Cache-Control', 'max-age=300');
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
return;
|
||||
if (!(file.isLink && file.uri)) return '204';
|
||||
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
|
||||
return {
|
||||
...result,
|
||||
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
|
||||
file,
|
||||
}
|
||||
|
||||
reply.code(204);
|
||||
reply.header('Cache-Control', 'max-age=86400');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThumbnail || isWebpublic) {
|
||||
const { mime, ext } = await this.fileInfoService.detectType(this.internalStorageService.resolvePath(key));
|
||||
const filename = rename(file.name, {
|
||||
suffix: isThumbnail ? '-thumb' : '-web',
|
||||
extname: ext ? `.${ext}` : undefined,
|
||||
}).toString();
|
||||
const path = this.internalStorageService.resolvePath(key);
|
||||
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', filename));
|
||||
return this.internalStorageService.read(key);
|
||||
} else {
|
||||
const readable = this.internalStorageService.read(file.accessKey!);
|
||||
readable.on('error', this.commonReadableHandlerGenerator(reply));
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', file.name));
|
||||
return readable;
|
||||
if (isThumbnail || isWebpublic) {
|
||||
const { mime, ext } = await this.fileInfoService.detectType(path);
|
||||
return {
|
||||
state: 'stored_internal',
|
||||
fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
|
||||
file,
|
||||
mime, ext,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'stored_internal',
|
||||
fileRole: 'original',
|
||||
file,
|
||||
mime: file.type,
|
||||
ext: null,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,177 +0,0 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { FileInfoService } from '@/core/FileInfoService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const assets = `${_dirname}/../../src/server/assets/`;
|
||||
|
||||
@Injectable()
|
||||
export class MediaProxyServerService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private fileInfoService: FileInfoService,
|
||||
private downloadService: DownloadService,
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.addHook('onRequest', (request, reply, done) => {
|
||||
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
|
||||
done();
|
||||
});
|
||||
|
||||
fastify.register(fastifyStatic, {
|
||||
root: _dirname,
|
||||
serve: false,
|
||||
});
|
||||
|
||||
fastify.get<{
|
||||
Params: { url: string; };
|
||||
Querystring: { url?: string; };
|
||||
}>('/:url*', async (request, reply) => await this.handler(request, reply));
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async handler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) {
|
||||
const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
|
||||
|
||||
if (typeof url !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
try {
|
||||
await this.downloadService.downloadUrl(url, path);
|
||||
|
||||
const { mime, ext } = await this.fileInfoService.detectType(path);
|
||||
const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image');
|
||||
const isAnimationConvertibleImage = isMimeImage(mime, 'sharp-animation-convertible-image');
|
||||
|
||||
let image: IImage;
|
||||
if ('emoji' in request.query && isConvertibleImage) {
|
||||
if (!isAnimationConvertibleImage && !('static' in request.query)) {
|
||||
image = {
|
||||
data: fs.readFileSync(path),
|
||||
ext,
|
||||
type: mime,
|
||||
};
|
||||
} else {
|
||||
const data = await sharp(path, { animated: !('static' in request.query) })
|
||||
.resize({
|
||||
height: 128,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp(webpDefault)
|
||||
.toBuffer();
|
||||
|
||||
image = {
|
||||
data,
|
||||
ext: 'webp',
|
||||
type: 'image/webp',
|
||||
};
|
||||
}
|
||||
} else if ('static' in request.query && isConvertibleImage) {
|
||||
image = await this.imageProcessingService.convertToWebp(path, 498, 280);
|
||||
} else if ('preview' in request.query && isConvertibleImage) {
|
||||
image = await this.imageProcessingService.convertToWebp(path, 200, 200);
|
||||
} else if ('badge' in request.query) {
|
||||
if (!isConvertibleImage) {
|
||||
// 画像でないなら404でお茶を濁す
|
||||
throw new StatusError('Unexpected mime', 404);
|
||||
}
|
||||
|
||||
const mask = sharp(path)
|
||||
.resize(96, 96, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: false,
|
||||
})
|
||||
.greyscale()
|
||||
.normalise()
|
||||
.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
|
||||
.flatten({ background: '#000' })
|
||||
.toColorspace('b-w');
|
||||
|
||||
const stats = await mask.clone().stats();
|
||||
|
||||
if (stats.entropy < 0.1) {
|
||||
// エントロピーがあまりない場合は404にする
|
||||
throw new StatusError('Skip to provide badge', 404);
|
||||
}
|
||||
|
||||
const data = sharp({
|
||||
create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
||||
})
|
||||
.pipelineColorspace('b-w')
|
||||
.boolean(await mask.png().toBuffer(), 'eor');
|
||||
|
||||
image = {
|
||||
data: await data.png().toBuffer(),
|
||||
ext: 'png',
|
||||
type: 'image/png',
|
||||
};
|
||||
} else if (mime === 'image/svg+xml') {
|
||||
image = await this.imageProcessingService.convertToWebp(path, 2048, 2048, webpDefault);
|
||||
} else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) {
|
||||
throw new StatusError('Rejected type', 403, 'Rejected type');
|
||||
} else {
|
||||
image = {
|
||||
data: fs.readFileSync(path),
|
||||
ext,
|
||||
type: mime,
|
||||
};
|
||||
}
|
||||
|
||||
reply.header('Content-Type', image.type);
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
return image.data;
|
||||
} catch (err) {
|
||||
this.logger.error(`${err}`);
|
||||
|
||||
if ('fallback' in request.query) {
|
||||
return reply.sendFile('/dummy.png', assets);
|
||||
}
|
||||
|
||||
if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {
|
||||
reply.code(err.statusCode);
|
||||
} else {
|
||||
reply.code(500);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
@@ -111,9 +111,6 @@ export class NodeinfoServerService {
|
||||
enableHcaptcha: meta.enableHcaptcha,
|
||||
enableRecaptcha: meta.enableRecaptcha,
|
||||
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
|
||||
enableTwitterIntegration: meta.enableTwitterIntegration,
|
||||
enableGithubIntegration: meta.enableGithubIntegration,
|
||||
enableDiscordIntegration: meta.enableDiscordIntegration,
|
||||
enableEmail: meta.enableEmail,
|
||||
enableServiceWorker: meta.enableServiceWorker,
|
||||
proxyAccountName: proxyAccount ? proxyAccount.username : null,
|
||||
|
@@ -3,14 +3,10 @@ import { EndpointsModule } from '@/server/api/EndpointsModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { ApiCallService } from './api/ApiCallService.js';
|
||||
import { FileServerService } from './FileServerService.js';
|
||||
import { MediaProxyServerService } from './MediaProxyServerService.js';
|
||||
import { NodeinfoServerService } from './NodeinfoServerService.js';
|
||||
import { ServerService } from './ServerService.js';
|
||||
import { WellKnownServerService } from './WellKnownServerService.js';
|
||||
import { GetterService } from './api/GetterService.js';
|
||||
import { DiscordServerService } from './api/integration/DiscordServerService.js';
|
||||
import { GithubServerService } from './api/integration/GithubServerService.js';
|
||||
import { TwitterServerService } from './api/integration/TwitterServerService.js';
|
||||
import { ChannelsService } from './api/stream/ChannelsService.js';
|
||||
import { ActivityPubServerService } from './ActivityPubServerService.js';
|
||||
import { ApiLoggerService } from './api/ApiLoggerService.js';
|
||||
@@ -51,14 +47,10 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||
UrlPreviewService,
|
||||
ActivityPubServerService,
|
||||
FileServerService,
|
||||
MediaProxyServerService,
|
||||
NodeinfoServerService,
|
||||
ServerService,
|
||||
WellKnownServerService,
|
||||
GetterService,
|
||||
DiscordServerService,
|
||||
GithubServerService,
|
||||
TwitterServerService,
|
||||
ChannelsService,
|
||||
ApiCallService,
|
||||
ApiLoggerService,
|
||||
|
@@ -20,7 +20,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js';
|
||||
import { ApiServerService } from './api/ApiServerService.js';
|
||||
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
|
||||
import { WellKnownServerService } from './WellKnownServerService.js';
|
||||
import { MediaProxyServerService } from './MediaProxyServerService.js';
|
||||
import { FileServerService } from './FileServerService.js';
|
||||
import { ClientServerService } from './web/ClientServerService.js';
|
||||
|
||||
@@ -48,7 +47,6 @@ export class ServerService {
|
||||
private wellKnownServerService: WellKnownServerService,
|
||||
private nodeinfoServerService: NodeinfoServerService,
|
||||
private fileServerService: FileServerService,
|
||||
private mediaProxyServerService: MediaProxyServerService,
|
||||
private clientServerService: ClientServerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private loggerService: LoggerService,
|
||||
@@ -73,8 +71,7 @@ export class ServerService {
|
||||
}
|
||||
|
||||
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
|
||||
fastify.register(this.fileServerService.createServer, { prefix: '/files' });
|
||||
fastify.register(this.mediaProxyServerService.createServer, { prefix: '/proxy' });
|
||||
fastify.register(this.fileServerService.createServer);
|
||||
fastify.register(this.activityPubServerService.createServer);
|
||||
fastify.register(this.nodeinfoServerService.createServer);
|
||||
fastify.register(this.wellKnownServerService.createServer);
|
||||
|
@@ -12,9 +12,6 @@ import endpoints, { IEndpoint } from './endpoints.js';
|
||||
import { ApiCallService } from './ApiCallService.js';
|
||||
import { SignupApiService } from './SignupApiService.js';
|
||||
import { SigninApiService } from './SigninApiService.js';
|
||||
import { GithubServerService } from './integration/GithubServerService.js';
|
||||
import { DiscordServerService } from './integration/DiscordServerService.js';
|
||||
import { TwitterServerService } from './integration/TwitterServerService.js';
|
||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
@@ -38,9 +35,6 @@ export class ApiServerService {
|
||||
private apiCallService: ApiCallService,
|
||||
private signupApiService: SignupApiService,
|
||||
private signinApiService: SigninApiService,
|
||||
private githubServerService: GithubServerService,
|
||||
private discordServerService: DiscordServerService,
|
||||
private twitterServerService: TwitterServerService,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
@@ -133,10 +127,6 @@ export class ApiServerService {
|
||||
|
||||
fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply));
|
||||
|
||||
fastify.register(this.discordServerService.create);
|
||||
fastify.register(this.githubServerService.create);
|
||||
fastify.register(this.twitterServerService.create);
|
||||
|
||||
fastify.get('/v1/instance/peers', async (request, reply) => {
|
||||
const instances = await this.instancesRepository.find({
|
||||
select: ['host'],
|
||||
|
@@ -97,7 +97,6 @@ import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
||||
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
||||
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
||||
import * as ep___charts_federation from './endpoints/charts/federation.js';
|
||||
import * as ep___charts_hashtag from './endpoints/charts/hashtag.js';
|
||||
import * as ep___charts_instance from './endpoints/charts/instance.js';
|
||||
import * as ep___charts_notes from './endpoints/charts/notes.js';
|
||||
import * as ep___charts_user_drive from './endpoints/charts/user/drive.js';
|
||||
@@ -433,7 +432,6 @@ const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useCl
|
||||
const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default };
|
||||
const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default };
|
||||
const $charts_federation: Provider = { provide: 'ep:charts/federation', useClass: ep___charts_federation.default };
|
||||
const $charts_hashtag: Provider = { provide: 'ep:charts/hashtag', useClass: ep___charts_hashtag.default };
|
||||
const $charts_instance: Provider = { provide: 'ep:charts/instance', useClass: ep___charts_instance.default };
|
||||
const $charts_notes: Provider = { provide: 'ep:charts/notes', useClass: ep___charts_notes.default };
|
||||
const $charts_user_drive: Provider = { provide: 'ep:charts/user/drive', useClass: ep___charts_user_drive.default };
|
||||
@@ -773,7 +771,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$charts_apRequest,
|
||||
$charts_drive,
|
||||
$charts_federation,
|
||||
$charts_hashtag,
|
||||
$charts_instance,
|
||||
$charts_notes,
|
||||
$charts_user_drive,
|
||||
@@ -1107,7 +1104,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$charts_apRequest,
|
||||
$charts_drive,
|
||||
$charts_federation,
|
||||
$charts_hashtag,
|
||||
$charts_instance,
|
||||
$charts_notes,
|
||||
$charts_user_drive,
|
||||
|
@@ -96,7 +96,6 @@ import * as ep___charts_activeUsers from './endpoints/charts/active-users.js';
|
||||
import * as ep___charts_apRequest from './endpoints/charts/ap-request.js';
|
||||
import * as ep___charts_drive from './endpoints/charts/drive.js';
|
||||
import * as ep___charts_federation from './endpoints/charts/federation.js';
|
||||
import * as ep___charts_hashtag from './endpoints/charts/hashtag.js';
|
||||
import * as ep___charts_instance from './endpoints/charts/instance.js';
|
||||
import * as ep___charts_notes from './endpoints/charts/notes.js';
|
||||
import * as ep___charts_user_drive from './endpoints/charts/user/drive.js';
|
||||
@@ -430,7 +429,6 @@ const eps = [
|
||||
['charts/ap-request', ep___charts_apRequest],
|
||||
['charts/drive', ep___charts_drive],
|
||||
['charts/federation', ep___charts_federation],
|
||||
['charts/hashtag', ep___charts_hashtag],
|
||||
['charts/instance', ep___charts_instance],
|
||||
['charts/notes', ep___charts_notes],
|
||||
['charts/user/drive', ep___charts_user_drive],
|
||||
|
@@ -3,6 +3,8 @@ import { DataSource, In } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -35,6 +37,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emojis = await this.emojisRepository.findBy({
|
||||
@@ -49,6 +54,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: await this.emojiEntityService.packMany(ps.ids),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -2,12 +2,10 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import rndstr from 'rndstr';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { DriveFilesRepository, EmojisRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -39,43 +37,26 @@ export const paramDef = {
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (file == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
|
||||
const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;
|
||||
const name = driveFile.name.split('.')[0].match(/^[a-z0-9_]+$/) ? driveFile.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`;
|
||||
|
||||
const emoji = await this.emojisRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
updatedAt: new Date(),
|
||||
name: name,
|
||||
const emoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
name,
|
||||
category: null,
|
||||
host: null,
|
||||
aliases: [],
|
||||
originalUrl: file.url,
|
||||
publicUrl: file.webpublicUrl ?? file.url,
|
||||
type: file.webpublicType ?? file.type,
|
||||
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiAdded', {
|
||||
emoji: await this.emojiEntityService.pack(emoji.id),
|
||||
host: null,
|
||||
});
|
||||
|
||||
this.moderationLogService.insertModerationLog(me, 'addEmoji', {
|
||||
|
@@ -4,6 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -35,6 +37,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emojis = await this.emojisRepository.findBy({
|
||||
@@ -43,13 +47,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
for (const emoji of emojis) {
|
||||
await this.emojisRepository.delete(emoji.id);
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.moderationLogService.insertModerationLog(me, 'deleteEmoji', {
|
||||
emoji: emoji,
|
||||
});
|
||||
}
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiDeleted', {
|
||||
emojis: await this.emojiEntityService.packMany(emojis),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,8 @@ import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -42,6 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emoji = await this.emojisRepository.findOneBy({ id: ps.id });
|
||||
@@ -52,6 +56,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiDeleted', {
|
||||
emojis: [ await this.emojiEntityService.pack(emoji) ],
|
||||
});
|
||||
|
||||
this.moderationLogService.insertModerationLog(me, 'deleteEmoji', {
|
||||
emoji: emoji,
|
||||
});
|
||||
|
@@ -101,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return this.emojiEntityService.packMany(emojis);
|
||||
return this.emojiEntityService.packMany(emojis, { omitHost: false, omitId: false, withUrl: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
emojis = await q.take(ps.limit).getMany();
|
||||
}
|
||||
|
||||
return this.emojiEntityService.packMany(emojis);
|
||||
return this.emojiEntityService.packMany(emojis, { omitHost: false, omitId: false, withUrl: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,8 @@ import { DataSource, In } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -35,6 +37,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emojis = await this.emojisRepository.findBy({
|
||||
@@ -49,6 +54,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: await this.emojiEntityService.packMany(ps.ids),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,8 @@ import { DataSource, In } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -35,6 +37,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.emojisRepository.update({
|
||||
@@ -45,6 +50,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
});
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: await this.emojiEntityService.packMany(ps.ids),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,8 @@ import { DataSource, In } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -37,6 +39,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.emojisRepository.update({
|
||||
@@ -47,6 +52,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
});
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: await this.emojiEntityService.packMany(ps.ids),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
@@ -48,6 +50,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emoji = await this.emojisRepository.findOneBy({ id: ps.id });
|
||||
@@ -62,6 +67,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
});
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
const updated = await this.emojiEntityService.pack(emoji.id);
|
||||
|
||||
if (emoji.name === ps.name) {
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: [ updated ],
|
||||
});
|
||||
} else {
|
||||
this.globalEventService.publishBroadcastStream('emojiDeleted', {
|
||||
emojis: [ await this.emojiEntityService.pack(emoji) ],
|
||||
});
|
||||
|
||||
this.globalEventService.publishBroadcastStream('emojiAdded', {
|
||||
emoji: updated,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -138,18 +138,6 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableTwitterIntegration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableGithubIntegration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableDiscordIntegration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServiceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -223,30 +211,6 @@ export const meta = {
|
||||
optional: true, nullable: true,
|
||||
format: 'id',
|
||||
},
|
||||
twitterConsumerKey: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
twitterConsumerSecret: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
githubClientId: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
githubClientSecret: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
discordClientId: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
discordClientSecret: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
summaryProxy: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
@@ -389,9 +353,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
defaultLightTheme: instance.defaultLightTheme,
|
||||
defaultDarkTheme: instance.defaultDarkTheme,
|
||||
enableEmail: instance.enableEmail,
|
||||
enableTwitterIntegration: instance.enableTwitterIntegration,
|
||||
enableGithubIntegration: instance.enableGithubIntegration,
|
||||
enableDiscordIntegration: instance.enableDiscordIntegration,
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
pinnedPages: instance.pinnedPages,
|
||||
@@ -409,12 +370,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
|
||||
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
|
||||
proxyAccountId: instance.proxyAccountId,
|
||||
twitterConsumerKey: instance.twitterConsumerKey,
|
||||
twitterConsumerSecret: instance.twitterConsumerSecret,
|
||||
githubClientId: instance.githubClientId,
|
||||
githubClientSecret: instance.githubClientSecret,
|
||||
discordClientId: instance.discordClientId,
|
||||
discordClientSecret: instance.discordClientSecret,
|
||||
summalyProxy: instance.summalyProxy,
|
||||
email: instance.email,
|
||||
smtpSecure: instance.smtpSecure,
|
||||
|
@@ -65,11 +65,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
};
|
||||
}
|
||||
|
||||
const maskedKeys = ['accessToken', 'accessTokenSecret', 'refreshToken'];
|
||||
Object.keys(profile.integrations).forEach(integration => {
|
||||
maskedKeys.forEach(key => profile.integrations[integration][key] = '<MASKED>');
|
||||
});
|
||||
|
||||
const signins = await this.signinsRepository.findBy({ userId: user.id });
|
||||
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
@@ -84,7 +79,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
carefulBot: profile.carefulBot,
|
||||
injectFeaturedNote: profile.injectFeaturedNote,
|
||||
receiveAnnouncementEmail: profile.receiveAnnouncementEmail,
|
||||
integrations: profile.integrations,
|
||||
mutedWords: profile.mutedWords,
|
||||
mutedInstances: profile.mutedInstances,
|
||||
mutingNotificationTypes: profile.mutingNotificationTypes,
|
||||
|
@@ -68,15 +68,6 @@ export const paramDef = {
|
||||
summalyProxy: { type: 'string', nullable: true },
|
||||
deeplAuthKey: { type: 'string', nullable: true },
|
||||
deeplIsPro: { type: 'boolean' },
|
||||
enableTwitterIntegration: { type: 'boolean' },
|
||||
twitterConsumerKey: { type: 'string', nullable: true },
|
||||
twitterConsumerSecret: { type: 'string', nullable: true },
|
||||
enableGithubIntegration: { type: 'boolean' },
|
||||
githubClientId: { type: 'string', nullable: true },
|
||||
githubClientSecret: { type: 'string', nullable: true },
|
||||
enableDiscordIntegration: { type: 'boolean' },
|
||||
discordClientId: { type: 'string', nullable: true },
|
||||
discordClientSecret: { type: 'string', nullable: true },
|
||||
enableEmail: { type: 'boolean' },
|
||||
email: { type: 'string', nullable: true },
|
||||
smtpSecure: { type: 'boolean' },
|
||||
@@ -270,42 +261,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
set.summalyProxy = ps.summalyProxy;
|
||||
}
|
||||
|
||||
if (ps.enableTwitterIntegration !== undefined) {
|
||||
set.enableTwitterIntegration = ps.enableTwitterIntegration;
|
||||
}
|
||||
|
||||
if (ps.twitterConsumerKey !== undefined) {
|
||||
set.twitterConsumerKey = ps.twitterConsumerKey;
|
||||
}
|
||||
|
||||
if (ps.twitterConsumerSecret !== undefined) {
|
||||
set.twitterConsumerSecret = ps.twitterConsumerSecret;
|
||||
}
|
||||
|
||||
if (ps.enableGithubIntegration !== undefined) {
|
||||
set.enableGithubIntegration = ps.enableGithubIntegration;
|
||||
}
|
||||
|
||||
if (ps.githubClientId !== undefined) {
|
||||
set.githubClientId = ps.githubClientId;
|
||||
}
|
||||
|
||||
if (ps.githubClientSecret !== undefined) {
|
||||
set.githubClientSecret = ps.githubClientSecret;
|
||||
}
|
||||
|
||||
if (ps.enableDiscordIntegration !== undefined) {
|
||||
set.enableDiscordIntegration = ps.enableDiscordIntegration;
|
||||
}
|
||||
|
||||
if (ps.discordClientId !== undefined) {
|
||||
set.discordClientId = ps.discordClientId;
|
||||
}
|
||||
|
||||
if (ps.discordClientSecret !== undefined) {
|
||||
set.discordClientSecret = ps.discordClientSecret;
|
||||
}
|
||||
|
||||
if (ps.enableEmail !== undefined) {
|
||||
set.enableEmail = ps.enableEmail;
|
||||
}
|
||||
|
@@ -1,37 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { getJsonSchema } from '@/core/chart/core.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import { schema } from '@/core/chart/charts/entities/hashtag.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['charts', 'hashtags'],
|
||||
|
||||
res: getJsonSchema(schema),
|
||||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
span: { type: 'string', enum: ['day', 'hour'] },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 500, default: 30 },
|
||||
offset: { type: 'integer', nullable: true, default: null },
|
||||
tag: { type: 'string' },
|
||||
},
|
||||
required: ['span', 'tag'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
private hashtagChart: HashtagChart,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
return await this.hashtagChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.tag);
|
||||
});
|
||||
}
|
||||
}
|
@@ -10,6 +10,8 @@ export const meta = {
|
||||
tags: ['meta'],
|
||||
|
||||
requireCredential: false,
|
||||
allowGet: true,
|
||||
cacheSec: 3600,
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
|
@@ -33,16 +33,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const res = await this.httpRequestService.fetch(
|
||||
ps.url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, */*',
|
||||
},
|
||||
// timeout: 5000,
|
||||
}
|
||||
);
|
||||
const res = await this.httpRequestService.send(ps.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/rss+xml, */*',
|
||||
},
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
|
@@ -169,18 +169,6 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableTwitterIntegration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableGithubIntegration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableDiscordIntegration: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServiceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -225,18 +213,6 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
twitter: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
github: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
discord: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
serviceWorker: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
@@ -325,11 +301,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
imageUrl: ad.imageUrl,
|
||||
})),
|
||||
enableEmail: instance.enableEmail,
|
||||
|
||||
enableTwitterIntegration: instance.enableTwitterIntegration,
|
||||
enableGithubIntegration: instance.enableGithubIntegration,
|
||||
enableDiscordIntegration: instance.enableDiscordIntegration,
|
||||
|
||||
enableServiceWorker: instance.enableServiceWorker,
|
||||
|
||||
translatorAvailable: instance.deeplAuthKey != null,
|
||||
@@ -358,9 +329,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
turnstile: instance.enableTurnstile,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
twitter: instance.enableTwitterIntegration,
|
||||
github: instance.enableGithubIntegration,
|
||||
discord: instance.enableDiscordIntegration,
|
||||
serviceWorker: instance.enableServiceWorker,
|
||||
miauth: true,
|
||||
};
|
||||
|
@@ -90,48 +90,13 @@ export const paramDef = {
|
||||
visibleUserIds: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string', format: 'misskey:id',
|
||||
} },
|
||||
text: { type: 'string', maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true },
|
||||
cw: { type: 'string', nullable: true, maxLength: 100 },
|
||||
localOnly: { type: 'boolean', default: false },
|
||||
noExtractMentions: { type: 'boolean', default: false },
|
||||
noExtractHashtags: { type: 'boolean', default: false },
|
||||
noExtractEmojis: { type: 'boolean', default: false },
|
||||
fileIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
mediaIds: {
|
||||
deprecated: true,
|
||||
description: 'Use `fileIds` instead. If both are specified, this property is discarded.',
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
replyId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
channelId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
poll: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
choices: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 2,
|
||||
maxItems: 10,
|
||||
items: { type: 'string', minLength: 1, maxLength: 50 },
|
||||
},
|
||||
multiple: { type: 'boolean', default: false },
|
||||
expiresAt: { type: 'integer', nullable: true },
|
||||
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
|
||||
},
|
||||
required: ['choices'],
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{
|
||||
@@ -143,21 +108,60 @@ export const paramDef = {
|
||||
},
|
||||
{
|
||||
// (re)note with files, text and poll are optional
|
||||
properties: {
|
||||
fileIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
},
|
||||
required: ['fileIds'],
|
||||
},
|
||||
{
|
||||
// (re)note with files, text and poll are optional
|
||||
properties: {
|
||||
mediaIds: {
|
||||
deprecated: true,
|
||||
description: 'Use `fileIds` instead. If both are specified, this property is discarded.',
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
},
|
||||
required: ['mediaIds'],
|
||||
},
|
||||
{
|
||||
// (re)note with poll, text and files are optional
|
||||
properties: {
|
||||
poll: { type: 'object', nullable: false },
|
||||
poll: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
choices: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 2,
|
||||
maxItems: 10,
|
||||
items: { type: 'string', minLength: 1, maxLength: 50 },
|
||||
},
|
||||
multiple: { type: 'boolean' },
|
||||
expiresAt: { type: 'integer', nullable: true },
|
||||
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
|
||||
},
|
||||
required: ['choices'],
|
||||
},
|
||||
},
|
||||
required: ['poll'],
|
||||
},
|
||||
{
|
||||
// pure renote
|
||||
properties: {
|
||||
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
},
|
||||
required: ['renoteId'],
|
||||
},
|
||||
],
|
||||
|
@@ -7,8 +7,8 @@ import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -83,20 +83,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
const res = await this.httpRequestService.fetch(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json, */*',
|
||||
},
|
||||
body: params.toString(),
|
||||
const res = await this.httpRequestService.send(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json, */*',
|
||||
},
|
||||
{
|
||||
noOkError: false,
|
||||
}
|
||||
);
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
translations: {
|
||||
|
@@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
|
||||
|
||||
if (me == null || (me.id !== ps.userId && !profile.publicReactions)) {
|
||||
if ((me == null || me.id !== ps.userId) && !profile.publicReactions) {
|
||||
throw new ApiError(meta.errors.reactionsNotPublic);
|
||||
}
|
||||
|
||||
|
@@ -29,14 +29,22 @@ export const meta = {
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', nullable: true },
|
||||
host: { type: 'string', nullable: true },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
detail: { type: 'boolean', default: true },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['username'] },
|
||||
{ required: ['host'] },
|
||||
{
|
||||
properties: {
|
||||
username: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username']
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
host: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['host']
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
@@ -1,308 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { OAuth2 } from 'oauth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import type { ILocalUser } from '@/models/entities/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { SigninService } from '../SigninService.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyPluginOptions } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class DiscordServerService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private metaService: MetaService,
|
||||
private signinService: SigninService,
|
||||
) {
|
||||
//this.create = this.create.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.get('/disconnect/discord', async (request, reply) => {
|
||||
if (!this.compareOrigin(request)) {
|
||||
throw new FastifyReplyError(400, 'invalid origin');
|
||||
}
|
||||
|
||||
const userToken = this.getUserToken(request);
|
||||
if (!userToken) {
|
||||
throw new FastifyReplyError(400, 'signin required');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({
|
||||
host: IsNull(),
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
delete profile.integrations.discord;
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
integrations: profile.integrations,
|
||||
});
|
||||
|
||||
// Publish i updated event
|
||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return 'Discordの連携を解除しました :v:';
|
||||
});
|
||||
|
||||
const getOAuth2 = async () => {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
if (meta.enableDiscordIntegration) {
|
||||
return new OAuth2(
|
||||
meta.discordClientId!,
|
||||
meta.discordClientSecret!,
|
||||
'https://discord.com/',
|
||||
'api/oauth2/authorize',
|
||||
'api/oauth2/token');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
fastify.get('/connect/discord', async (request, reply) => {
|
||||
if (!this.compareOrigin(request)) {
|
||||
throw new FastifyReplyError(400, 'invalid origin');
|
||||
}
|
||||
|
||||
const userToken = this.getUserToken(request);
|
||||
if (!userToken) {
|
||||
throw new FastifyReplyError(400, 'signin required');
|
||||
}
|
||||
|
||||
const params = {
|
||||
redirect_uri: `${this.config.url}/api/dc/cb`,
|
||||
scope: ['identify'],
|
||||
state: uuid(),
|
||||
response_type: 'code',
|
||||
};
|
||||
|
||||
this.redisClient.set(userToken, JSON.stringify(params));
|
||||
|
||||
const oauth2 = await getOAuth2();
|
||||
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||
});
|
||||
|
||||
fastify.get('/signin/discord', async (request, reply) => {
|
||||
const sessid = uuid();
|
||||
|
||||
const params = {
|
||||
redirect_uri: `${this.config.url}/api/dc/cb`,
|
||||
scope: ['identify'],
|
||||
state: uuid(),
|
||||
response_type: 'code',
|
||||
};
|
||||
|
||||
reply.setCookie('signin_with_discord_sid', sessid, {
|
||||
path: '/',
|
||||
secure: this.config.url.startsWith('https'),
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
this.redisClient.set(sessid, JSON.stringify(params));
|
||||
|
||||
const oauth2 = await getOAuth2();
|
||||
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||
});
|
||||
|
||||
fastify.get('/dc/cb', async (request, reply) => {
|
||||
const userToken = this.getUserToken(request);
|
||||
|
||||
const oauth2 = await getOAuth2();
|
||||
|
||||
if (!userToken) {
|
||||
const sessid = request.cookies['signin_with_discord_sid'];
|
||||
|
||||
if (!sessid) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const code = request.query.code;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||
this.redisClient.get(sessid, async (_, state) => {
|
||||
if (state == null) throw new Error('empty state');
|
||||
res(JSON.parse(state));
|
||||
});
|
||||
});
|
||||
|
||||
if (request.query.state !== state) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
|
||||
oauth2!.getOAuthAccessToken(code, {
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri,
|
||||
}, (err, accessToken, refreshToken, result) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
} else if (result.error) {
|
||||
rej(result.error);
|
||||
} else {
|
||||
res({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresDate: Date.now() + Number(result.expires_in) * 1000,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const { id, username, discriminator } = (await this.httpRequestService.getJson('https://discord.com/api/users/@me', '*/*', {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
})) as Record<string, unknown>;
|
||||
|
||||
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.createQueryBuilder()
|
||||
.where('"integrations"->\'discord\'->>\'id\' = :id', { id: id })
|
||||
.andWhere('"userHost" IS NULL')
|
||||
.getOne();
|
||||
|
||||
if (profile == null) {
|
||||
throw new FastifyReplyError(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`);
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update(profile.userId, {
|
||||
integrations: {
|
||||
...profile.integrations,
|
||||
discord: {
|
||||
id: id,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresDate: expiresDate,
|
||||
username: username,
|
||||
discriminator: discriminator,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: profile.userId }) as ILocalUser, true);
|
||||
} else {
|
||||
const code = request.query.code;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||
this.redisClient.get(userToken, async (_, state) => {
|
||||
if (state == null) throw new Error('empty state');
|
||||
res(JSON.parse(state));
|
||||
});
|
||||
});
|
||||
|
||||
if (request.query.state !== state) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
|
||||
oauth2!.getOAuthAccessToken(code, {
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri,
|
||||
}, (err, accessToken, refreshToken, result) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
} else if (result.error) {
|
||||
rej(result.error);
|
||||
} else {
|
||||
res({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresDate: Date.now() + Number(result.expires_in) * 1000,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const { id, username, discriminator } = (await this.httpRequestService.getJson('https://discord.com/api/users/@me', '*/*', {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
})) as Record<string, unknown>;
|
||||
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({
|
||||
host: IsNull(),
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
integrations: {
|
||||
...profile.integrations,
|
||||
discord: {
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresDate: expiresDate,
|
||||
id: id,
|
||||
username: username,
|
||||
discriminator: discriminator,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Publish i updated event
|
||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`;
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getUserToken(request: FastifyRequest): string | null {
|
||||
return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private compareOrigin(request: FastifyRequest): boolean {
|
||||
function normalizeUrl(url?: string): string {
|
||||
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
||||
}
|
||||
|
||||
const referer = request.headers['referer'];
|
||||
|
||||
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
||||
}
|
||||
}
|
@@ -1,280 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { OAuth2 } from 'oauth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import type { ILocalUser } from '@/models/entities/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { SigninService } from '../SigninService.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyPluginOptions } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class GithubServerService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private metaService: MetaService,
|
||||
private signinService: SigninService,
|
||||
) {
|
||||
//this.create = this.create.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.get('/disconnect/github', async (request, reply) => {
|
||||
if (!this.compareOrigin(request)) {
|
||||
throw new FastifyReplyError(400, 'invalid origin');
|
||||
}
|
||||
|
||||
const userToken = this.getUserToken(request);
|
||||
if (!userToken) {
|
||||
throw new FastifyReplyError(400, 'signin required');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({
|
||||
host: IsNull(),
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
delete profile.integrations.github;
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
integrations: profile.integrations,
|
||||
});
|
||||
|
||||
// Publish i updated event
|
||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return 'GitHubの連携を解除しました :v:';
|
||||
});
|
||||
|
||||
const getOath2 = async () => {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) {
|
||||
return new OAuth2(
|
||||
meta.githubClientId,
|
||||
meta.githubClientSecret,
|
||||
'https://github.com/',
|
||||
'login/oauth/authorize',
|
||||
'login/oauth/access_token');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
fastify.get('/connect/github', async (request, reply) => {
|
||||
if (!this.compareOrigin(request)) {
|
||||
throw new FastifyReplyError(400, 'invalid origin');
|
||||
}
|
||||
|
||||
const userToken = this.getUserToken(request);
|
||||
if (!userToken) {
|
||||
throw new FastifyReplyError(400, 'signin required');
|
||||
}
|
||||
|
||||
const params = {
|
||||
redirect_uri: `${this.config.url}/api/gh/cb`,
|
||||
scope: ['read:user'],
|
||||
state: uuid(),
|
||||
};
|
||||
|
||||
this.redisClient.set(userToken, JSON.stringify(params));
|
||||
|
||||
const oauth2 = await getOath2();
|
||||
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||
});
|
||||
|
||||
fastify.get('/signin/github', async (request, reply) => {
|
||||
const sessid = uuid();
|
||||
|
||||
const params = {
|
||||
redirect_uri: `${this.config.url}/api/gh/cb`,
|
||||
scope: ['read:user'],
|
||||
state: uuid(),
|
||||
};
|
||||
|
||||
reply.setCookie('signin_with_github_sid', sessid, {
|
||||
path: '/',
|
||||
secure: this.config.url.startsWith('https'),
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
this.redisClient.set(sessid, JSON.stringify(params));
|
||||
|
||||
const oauth2 = await getOath2();
|
||||
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||
});
|
||||
|
||||
fastify.get('/gh/cb', async (request, reply) => {
|
||||
const userToken = this.getUserToken(request);
|
||||
|
||||
const oauth2 = await getOath2();
|
||||
|
||||
if (!userToken) {
|
||||
const sessid = request.cookies['signin_with_github_sid'];
|
||||
|
||||
if (!sessid) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const code = request.query.code;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||
this.redisClient.get(sessid, async (_, state) => {
|
||||
if (state == null) throw new Error('empty state');
|
||||
res(JSON.parse(state));
|
||||
});
|
||||
});
|
||||
|
||||
if (request.query.state !== state) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) =>
|
||||
oauth2!.getOAuthAccessToken(code, {
|
||||
redirect_uri,
|
||||
}, (err, accessToken, refresh, result) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
} else if (result.error) {
|
||||
rej(result.error);
|
||||
} else {
|
||||
res({ accessToken });
|
||||
}
|
||||
}));
|
||||
|
||||
const { login, id } = (await this.httpRequestService.getJson('https://api.github.com/user', 'application/vnd.github.v3+json', {
|
||||
'Authorization': `bearer ${accessToken}`,
|
||||
})) as Record<string, unknown>;
|
||||
if (typeof login !== 'string' || typeof id !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const link = await this.userProfilesRepository.createQueryBuilder()
|
||||
.where('"integrations"->\'github\'->>\'id\' = :id', { id: id })
|
||||
.andWhere('"userHost" IS NULL')
|
||||
.getOne();
|
||||
|
||||
if (link == null) {
|
||||
throw new FastifyReplyError(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`);
|
||||
}
|
||||
|
||||
return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true);
|
||||
} else {
|
||||
const code = request.query.code;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||
this.redisClient.get(userToken, async (_, state) => {
|
||||
if (state == null) throw new Error('empty state');
|
||||
res(JSON.parse(state));
|
||||
});
|
||||
});
|
||||
|
||||
if (request.query.state !== state) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) =>
|
||||
oauth2!.getOAuthAccessToken(
|
||||
code,
|
||||
{ redirect_uri },
|
||||
(err, accessToken, refresh, result) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
} else if (result.error) {
|
||||
rej(result.error);
|
||||
} else {
|
||||
res({ accessToken });
|
||||
}
|
||||
}));
|
||||
|
||||
const { login, id } = (await this.httpRequestService.getJson('https://api.github.com/user', 'application/vnd.github.v3+json', {
|
||||
'Authorization': `bearer ${accessToken}`,
|
||||
})) as Record<string, unknown>;
|
||||
|
||||
if (typeof login !== 'string' || typeof id !== 'number') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({
|
||||
host: IsNull(),
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
integrations: {
|
||||
...profile.integrations,
|
||||
github: {
|
||||
accessToken: accessToken,
|
||||
id: id,
|
||||
login: login,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Publish i updated event
|
||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`;
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getUserToken(request: FastifyRequest): string | null {
|
||||
return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private compareOrigin(request: FastifyRequest): boolean {
|
||||
function normalizeUrl(url?: string): string {
|
||||
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
||||
}
|
||||
|
||||
const referer = request.headers['referer'];
|
||||
|
||||
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
||||
}
|
||||
}
|
@@ -1,225 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IsNull } from 'typeorm';
|
||||
import * as autwh from 'autwh';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import type { ILocalUser } from '@/models/entities/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { SigninService } from '../SigninService.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyPluginOptions } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class TwitterServerService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private metaService: MetaService,
|
||||
private signinService: SigninService,
|
||||
) {
|
||||
//this.create = this.create.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||
fastify.get('/disconnect/twitter', async (request, reply) => {
|
||||
if (!this.compareOrigin(request)) {
|
||||
throw new FastifyReplyError(400, 'invalid origin');
|
||||
}
|
||||
|
||||
const userToken = this.getUserToken(request);
|
||||
if (userToken == null) {
|
||||
throw new FastifyReplyError(400, 'signin required');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({
|
||||
host: IsNull(),
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
delete profile.integrations.twitter;
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
integrations: profile.integrations,
|
||||
});
|
||||
|
||||
// Publish i updated event
|
||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return 'Twitterの連携を解除しました :v:';
|
||||
});
|
||||
|
||||
const getTwAuth = async () => {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) {
|
||||
return autwh({
|
||||
consumerKey: meta.twitterConsumerKey,
|
||||
consumerSecret: meta.twitterConsumerSecret,
|
||||
callbackUrl: `${this.config.url}/api/tw/cb`,
|
||||
});
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
fastify.get('/connect/twitter', async (request, reply) => {
|
||||
if (!this.compareOrigin(request)) {
|
||||
throw new FastifyReplyError(400, 'invalid origin');
|
||||
}
|
||||
|
||||
const userToken = this.getUserToken(request);
|
||||
if (userToken == null) {
|
||||
throw new FastifyReplyError(400, 'signin required');
|
||||
}
|
||||
|
||||
const twAuth = await getTwAuth();
|
||||
const twCtx = await twAuth!.begin();
|
||||
this.redisClient.set(userToken, JSON.stringify(twCtx));
|
||||
reply.redirect(twCtx.url);
|
||||
});
|
||||
|
||||
fastify.get('/signin/twitter', async (request, reply) => {
|
||||
const twAuth = await getTwAuth();
|
||||
const twCtx = await twAuth!.begin();
|
||||
|
||||
const sessid = uuid();
|
||||
|
||||
this.redisClient.set(sessid, JSON.stringify(twCtx));
|
||||
|
||||
reply.setCookie('signin_with_twitter_sid', sessid, {
|
||||
path: '/',
|
||||
secure: this.config.url.startsWith('https'),
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
reply.redirect(twCtx.url);
|
||||
});
|
||||
|
||||
fastify.get('/tw/cb', async (request, reply) => {
|
||||
const userToken = this.getUserToken(request);
|
||||
|
||||
const twAuth = await getTwAuth();
|
||||
|
||||
if (userToken == null) {
|
||||
const sessid = request.cookies['signin_with_twitter_sid'];
|
||||
|
||||
if (sessid == null) {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const get = new Promise<any>((res, rej) => {
|
||||
this.redisClient.get(sessid, async (_, twCtx) => {
|
||||
res(twCtx);
|
||||
});
|
||||
});
|
||||
|
||||
const twCtx = await get;
|
||||
|
||||
const verifier = request.query.oauth_verifier;
|
||||
if (!verifier || typeof verifier !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const result = await twAuth!.done(JSON.parse(twCtx), verifier);
|
||||
|
||||
const link = await this.userProfilesRepository.createQueryBuilder()
|
||||
.where('"integrations"->\'twitter\'->>\'userId\' = :id', { id: result.userId })
|
||||
.andWhere('"userHost" IS NULL')
|
||||
.getOne();
|
||||
|
||||
if (link == null) {
|
||||
throw new FastifyReplyError(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`);
|
||||
}
|
||||
|
||||
return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true);
|
||||
} else {
|
||||
const verifier = request.query.oauth_verifier;
|
||||
|
||||
if (!verifier || typeof verifier !== 'string') {
|
||||
throw new FastifyReplyError(400, 'invalid session');
|
||||
}
|
||||
|
||||
const get = new Promise<any>((res, rej) => {
|
||||
this.redisClient.get(userToken, async (_, twCtx) => {
|
||||
res(twCtx);
|
||||
});
|
||||
});
|
||||
|
||||
const twCtx = await get;
|
||||
|
||||
const result = await twAuth!.done(JSON.parse(twCtx), verifier);
|
||||
|
||||
const user = await this.usersRepository.findOneByOrFail({
|
||||
host: IsNull(),
|
||||
token: userToken,
|
||||
});
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
integrations: {
|
||||
...profile.integrations,
|
||||
twitter: {
|
||||
accessToken: result.accessToken,
|
||||
accessTokenSecret: result.accessTokenSecret,
|
||||
userId: result.userId,
|
||||
screenName: result.screenName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Publish i updated event
|
||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
}));
|
||||
|
||||
return `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`;
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private getUserToken(request: FastifyRequest): string | null {
|
||||
return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private compareOrigin(request: FastifyRequest): boolean {
|
||||
function normalizeUrl(url?: string): string {
|
||||
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
||||
}
|
||||
|
||||
const referer = request.headers['referer'];
|
||||
|
||||
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
||||
}
|
||||
}
|
@@ -18,37 +18,42 @@ import { Following, Role, RoleAssignment } from '@/models';
|
||||
import type Emitter from 'strict-event-emitter-types';
|
||||
import type { EventEmitter } from 'events';
|
||||
|
||||
// redis通すとDateのインスタンスはstringに変換されるので
|
||||
type Serialized<T> = {
|
||||
[K in keyof T]: T[K] extends Date ? string : T[K];
|
||||
};
|
||||
|
||||
//#region Stream type-body definitions
|
||||
export interface InternalStreamTypes {
|
||||
userChangeSuspendedState: Serialized<{ id: User['id']; isSuspended: User['isSuspended']; }>;
|
||||
userTokenRegenerated: Serialized<{ id: User['id']; oldToken: User['token']; newToken: User['token']; }>;
|
||||
remoteUserUpdated: Serialized<{ id: User['id']; }>;
|
||||
follow: Serialized<{ followerId: User['id']; followeeId: User['id']; }>;
|
||||
unfollow: Serialized<{ followerId: User['id']; followeeId: User['id']; }>;
|
||||
policiesUpdated: Serialized<Role['options']>;
|
||||
roleCreated: Serialized<Role>;
|
||||
roleDeleted: Serialized<Role>;
|
||||
roleUpdated: Serialized<Role>;
|
||||
userRoleAssigned: Serialized<RoleAssignment>;
|
||||
userRoleUnassigned: Serialized<RoleAssignment>;
|
||||
webhookCreated: Serialized<Webhook>;
|
||||
webhookDeleted: Serialized<Webhook>;
|
||||
webhookUpdated: Serialized<Webhook>;
|
||||
antennaCreated: Serialized<Antenna>;
|
||||
antennaDeleted: Serialized<Antenna>;
|
||||
antennaUpdated: Serialized<Antenna>;
|
||||
metaUpdated: Serialized<Meta>;
|
||||
userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; };
|
||||
userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; };
|
||||
remoteUserUpdated: { id: User['id']; };
|
||||
follow: { followerId: User['id']; followeeId: User['id']; };
|
||||
unfollow: { followerId: User['id']; followeeId: User['id']; };
|
||||
policiesUpdated: Role['policies'];
|
||||
roleCreated: Role;
|
||||
roleDeleted: Role;
|
||||
roleUpdated: Role;
|
||||
userRoleAssigned: RoleAssignment;
|
||||
userRoleUnassigned: RoleAssignment;
|
||||
webhookCreated: Webhook;
|
||||
webhookDeleted: Webhook;
|
||||
webhookUpdated: Webhook;
|
||||
antennaCreated: Antenna;
|
||||
antennaDeleted: Antenna;
|
||||
antennaUpdated: Antenna;
|
||||
metaUpdated: Meta;
|
||||
}
|
||||
|
||||
export interface BroadcastTypes {
|
||||
emojiAdded: {
|
||||
emoji: Packed<'Emoji'>;
|
||||
};
|
||||
emojiUpdated: {
|
||||
emojis: Packed<'Emoji'>[];
|
||||
};
|
||||
emojiDeleted: {
|
||||
emojis: {
|
||||
id?: string;
|
||||
name: string;
|
||||
[other: string]: any;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserStreamTypes {
|
||||
@@ -200,63 +205,72 @@ type EventUnionFromDictionary<
|
||||
U = Events<T>
|
||||
> = U[keyof U];
|
||||
|
||||
// redis通すとDateのインスタンスはstringに変換されるので
|
||||
type Serialized<T> = {
|
||||
[K in keyof T]: T[K] extends Date ? string : T[K] extends Record<string, any> ? Serialized<T[K]> : T[K];
|
||||
};
|
||||
|
||||
type SerializedAll<T> = {
|
||||
[K in keyof T]: Serialized<T[K]>;
|
||||
};
|
||||
|
||||
// name/messages(spec) pairs dictionary
|
||||
export type StreamMessages = {
|
||||
internal: {
|
||||
name: 'internal';
|
||||
payload: EventUnionFromDictionary<InternalStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<InternalStreamTypes>>;
|
||||
};
|
||||
broadcast: {
|
||||
name: 'broadcast';
|
||||
payload: EventUnionFromDictionary<BroadcastTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
|
||||
};
|
||||
user: {
|
||||
name: `user:${User['id']}`;
|
||||
payload: EventUnionFromDictionary<UserStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<UserStreamTypes>>;
|
||||
};
|
||||
main: {
|
||||
name: `mainStream:${User['id']}`;
|
||||
payload: EventUnionFromDictionary<MainStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<MainStreamTypes>>;
|
||||
};
|
||||
drive: {
|
||||
name: `driveStream:${User['id']}`;
|
||||
payload: EventUnionFromDictionary<DriveStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<DriveStreamTypes>>;
|
||||
};
|
||||
note: {
|
||||
name: `noteStream:${Note['id']}`;
|
||||
payload: EventUnionFromDictionary<NoteStreamEventTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
|
||||
};
|
||||
channel: {
|
||||
name: `channelStream:${Channel['id']}`;
|
||||
payload: EventUnionFromDictionary<ChannelStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<ChannelStreamTypes>>;
|
||||
};
|
||||
userList: {
|
||||
name: `userListStream:${UserList['id']}`;
|
||||
payload: EventUnionFromDictionary<UserListStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
|
||||
};
|
||||
antenna: {
|
||||
name: `antennaStream:${Antenna['id']}`;
|
||||
payload: EventUnionFromDictionary<AntennaStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
|
||||
};
|
||||
messaging: {
|
||||
name: `messagingStream:${User['id']}-${User['id']}`;
|
||||
payload: EventUnionFromDictionary<MessagingStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<MessagingStreamTypes>>;
|
||||
};
|
||||
groupMessaging: {
|
||||
name: `messagingStream:${UserGroup['id']}`;
|
||||
payload: EventUnionFromDictionary<GroupMessagingStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<GroupMessagingStreamTypes>>;
|
||||
};
|
||||
messagingIndex: {
|
||||
name: `messagingIndexStream:${User['id']}`;
|
||||
payload: EventUnionFromDictionary<MessagingIndexStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<MessagingIndexStreamTypes>>;
|
||||
};
|
||||
admin: {
|
||||
name: `adminStream:${User['id']}`;
|
||||
payload: EventUnionFromDictionary<AdminStreamTypes>;
|
||||
payload: EventUnionFromDictionary<SerializedAll<AdminStreamTypes>>;
|
||||
};
|
||||
notes: {
|
||||
name: 'notesStream';
|
||||
payload: Packed<'Note'>;
|
||||
payload: Serialized<Packed<'Note'>>;
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -154,7 +154,7 @@
|
||||
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
|
||||
</svg>
|
||||
<h1>An error has occurred!</h1>
|
||||
<button class="button-big" onclick="location.reload(true);">
|
||||
<button class="button-big" onclick="location.reload();">
|
||||
<span class="button-label-big">Refresh</span>
|
||||
</button>
|
||||
<p class="dont-worry">Don't worry, it's (probably) not your fault.</p>
|
||||
|
@@ -100,90 +100,90 @@ describe('API visibility', () => {
|
||||
|
||||
//#region show post
|
||||
// public
|
||||
it('[show] public-postを自分が見れる', async () => {
|
||||
test('[show] public-postを自分が見れる', async () => {
|
||||
const res = await show(pub.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-postをフォロワーが見れる', async () => {
|
||||
test('[show] public-postをフォロワーが見れる', async () => {
|
||||
const res = await show(pub.id, follower);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-postを非フォロワーが見れる', async () => {
|
||||
test('[show] public-postを非フォロワーが見れる', async () => {
|
||||
const res = await show(pub.id, other);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-postを未認証が見れる', async () => {
|
||||
test('[show] public-postを未認証が見れる', async () => {
|
||||
const res = await show(pub.id, null);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
// home
|
||||
it('[show] home-postを自分が見れる', async () => {
|
||||
test('[show] home-postを自分が見れる', async () => {
|
||||
const res = await show(home.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-postをフォロワーが見れる', async () => {
|
||||
test('[show] home-postをフォロワーが見れる', async () => {
|
||||
const res = await show(home.id, follower);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-postを非フォロワーが見れる', async () => {
|
||||
test('[show] home-postを非フォロワーが見れる', async () => {
|
||||
const res = await show(home.id, other);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-postを未認証が見れる', async () => {
|
||||
test('[show] home-postを未認証が見れる', async () => {
|
||||
const res = await show(home.id, null);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
// followers
|
||||
it('[show] followers-postを自分が見れる', async () => {
|
||||
test('[show] followers-postを自分が見れる', async () => {
|
||||
const res = await show(fol.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] followers-postをフォロワーが見れる', async () => {
|
||||
test('[show] followers-postをフォロワーが見れる', async () => {
|
||||
const res = await show(fol.id, follower);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] followers-postを非フォロワーが見れない', async () => {
|
||||
test('[show] followers-postを非フォロワーが見れない', async () => {
|
||||
const res = await show(fol.id, other);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] followers-postを未認証が見れない', async () => {
|
||||
test('[show] followers-postを未認証が見れない', async () => {
|
||||
const res = await show(fol.id, null);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
// specified
|
||||
it('[show] specified-postを自分が見れる', async () => {
|
||||
test('[show] specified-postを自分が見れる', async () => {
|
||||
const res = await show(spe.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] specified-postを指定ユーザーが見れる', async () => {
|
||||
test('[show] specified-postを指定ユーザーが見れる', async () => {
|
||||
const res = await show(spe.id, target);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] specified-postをフォロワーが見れない', async () => {
|
||||
test('[show] specified-postをフォロワーが見れない', async () => {
|
||||
const res = await show(spe.id, follower);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-postを非フォロワーが見れない', async () => {
|
||||
test('[show] specified-postを非フォロワーが見れない', async () => {
|
||||
const res = await show(spe.id, other);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-postを未認証が見れない', async () => {
|
||||
test('[show] specified-postを未認証が見れない', async () => {
|
||||
const res = await show(spe.id, null);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
@@ -191,110 +191,110 @@ describe('API visibility', () => {
|
||||
|
||||
//#region show reply
|
||||
// public
|
||||
it('[show] public-replyを自分が見れる', async () => {
|
||||
test('[show] public-replyを自分が見れる', async () => {
|
||||
const res = await show(pubR.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-replyをされた人が見れる', async () => {
|
||||
test('[show] public-replyをされた人が見れる', async () => {
|
||||
const res = await show(pubR.id, target);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-replyをフォロワーが見れる', async () => {
|
||||
test('[show] public-replyをフォロワーが見れる', async () => {
|
||||
const res = await show(pubR.id, follower);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-replyを非フォロワーが見れる', async () => {
|
||||
test('[show] public-replyを非フォロワーが見れる', async () => {
|
||||
const res = await show(pubR.id, other);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] public-replyを未認証が見れる', async () => {
|
||||
test('[show] public-replyを未認証が見れる', async () => {
|
||||
const res = await show(pubR.id, null);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
// home
|
||||
it('[show] home-replyを自分が見れる', async () => {
|
||||
test('[show] home-replyを自分が見れる', async () => {
|
||||
const res = await show(homeR.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-replyをされた人が見れる', async () => {
|
||||
test('[show] home-replyをされた人が見れる', async () => {
|
||||
const res = await show(homeR.id, target);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-replyをフォロワーが見れる', async () => {
|
||||
test('[show] home-replyをフォロワーが見れる', async () => {
|
||||
const res = await show(homeR.id, follower);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-replyを非フォロワーが見れる', async () => {
|
||||
test('[show] home-replyを非フォロワーが見れる', async () => {
|
||||
const res = await show(homeR.id, other);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] home-replyを未認証が見れる', async () => {
|
||||
test('[show] home-replyを未認証が見れる', async () => {
|
||||
const res = await show(homeR.id, null);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
// followers
|
||||
it('[show] followers-replyを自分が見れる', async () => {
|
||||
test('[show] followers-replyを自分が見れる', async () => {
|
||||
const res = await show(folR.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async () => {
|
||||
test('[show] followers-replyを非フォロワーでもリプライされていれば見れる', async () => {
|
||||
const res = await show(folR.id, target);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] followers-replyをフォロワーが見れる', async () => {
|
||||
test('[show] followers-replyをフォロワーが見れる', async () => {
|
||||
const res = await show(folR.id, follower);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] followers-replyを非フォロワーが見れない', async () => {
|
||||
test('[show] followers-replyを非フォロワーが見れない', async () => {
|
||||
const res = await show(folR.id, other);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] followers-replyを未認証が見れない', async () => {
|
||||
test('[show] followers-replyを未認証が見れない', async () => {
|
||||
const res = await show(folR.id, null);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
// specified
|
||||
it('[show] specified-replyを自分が見れる', async () => {
|
||||
test('[show] specified-replyを自分が見れる', async () => {
|
||||
const res = await show(speR.id, alice);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] specified-replyを指定ユーザーが見れる', async () => {
|
||||
test('[show] specified-replyを指定ユーザーが見れる', async () => {
|
||||
const res = await show(speR.id, target);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] specified-replyをされた人が指定されてなくても見れる', async () => {
|
||||
test('[show] specified-replyをされた人が指定されてなくても見れる', async () => {
|
||||
const res = await show(speR.id, target);
|
||||
assert.strictEqual(res.body.text, 'x');
|
||||
});
|
||||
|
||||
it('[show] specified-replyをフォロワーが見れない', async () => {
|
||||
test('[show] specified-replyをフォロワーが見れない', async () => {
|
||||
const res = await show(speR.id, follower);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-replyを非フォロワーが見れない', async () => {
|
||||
test('[show] specified-replyを非フォロワーが見れない', async () => {
|
||||
const res = await show(speR.id, other);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-replyを未認証が見れない', async () => {
|
||||
test('[show] specified-replyを未認証が見れない', async () => {
|
||||
const res = await show(speR.id, null);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
@@ -302,131 +302,131 @@ describe('API visibility', () => {
|
||||
|
||||
//#region show mention
|
||||
// public
|
||||
it('[show] public-mentionを自分が見れる', async () => {
|
||||
test('[show] public-mentionを自分が見れる', async () => {
|
||||
const res = await show(pubM.id, alice);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] public-mentionをされた人が見れる', async () => {
|
||||
test('[show] public-mentionをされた人が見れる', async () => {
|
||||
const res = await show(pubM.id, target);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] public-mentionをフォロワーが見れる', async () => {
|
||||
test('[show] public-mentionをフォロワーが見れる', async () => {
|
||||
const res = await show(pubM.id, follower);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] public-mentionを非フォロワーが見れる', async () => {
|
||||
test('[show] public-mentionを非フォロワーが見れる', async () => {
|
||||
const res = await show(pubM.id, other);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] public-mentionを未認証が見れる', async () => {
|
||||
test('[show] public-mentionを未認証が見れる', async () => {
|
||||
const res = await show(pubM.id, null);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
// home
|
||||
it('[show] home-mentionを自分が見れる', async () => {
|
||||
test('[show] home-mentionを自分が見れる', async () => {
|
||||
const res = await show(homeM.id, alice);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] home-mentionをされた人が見れる', async () => {
|
||||
test('[show] home-mentionをされた人が見れる', async () => {
|
||||
const res = await show(homeM.id, target);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] home-mentionをフォロワーが見れる', async () => {
|
||||
test('[show] home-mentionをフォロワーが見れる', async () => {
|
||||
const res = await show(homeM.id, follower);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] home-mentionを非フォロワーが見れる', async () => {
|
||||
test('[show] home-mentionを非フォロワーが見れる', async () => {
|
||||
const res = await show(homeM.id, other);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] home-mentionを未認証が見れる', async () => {
|
||||
test('[show] home-mentionを未認証が見れる', async () => {
|
||||
const res = await show(homeM.id, null);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
// followers
|
||||
it('[show] followers-mentionを自分が見れる', async () => {
|
||||
test('[show] followers-mentionを自分が見れる', async () => {
|
||||
const res = await show(folM.id, alice);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async () => {
|
||||
test('[show] followers-mentionをメンションされていれば非フォロワーでも見れる', async () => {
|
||||
const res = await show(folM.id, target);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] followers-mentionをフォロワーが見れる', async () => {
|
||||
test('[show] followers-mentionをフォロワーが見れる', async () => {
|
||||
const res = await show(folM.id, follower);
|
||||
assert.strictEqual(res.body.text, '@target x');
|
||||
});
|
||||
|
||||
it('[show] followers-mentionを非フォロワーが見れない', async () => {
|
||||
test('[show] followers-mentionを非フォロワーが見れない', async () => {
|
||||
const res = await show(folM.id, other);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] followers-mentionを未認証が見れない', async () => {
|
||||
test('[show] followers-mentionを未認証が見れない', async () => {
|
||||
const res = await show(folM.id, null);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
// specified
|
||||
it('[show] specified-mentionを自分が見れる', async () => {
|
||||
test('[show] specified-mentionを自分が見れる', async () => {
|
||||
const res = await show(speM.id, alice);
|
||||
assert.strictEqual(res.body.text, '@target2 x');
|
||||
});
|
||||
|
||||
it('[show] specified-mentionを指定ユーザーが見れる', async () => {
|
||||
test('[show] specified-mentionを指定ユーザーが見れる', async () => {
|
||||
const res = await show(speM.id, target);
|
||||
assert.strictEqual(res.body.text, '@target2 x');
|
||||
});
|
||||
|
||||
it('[show] specified-mentionをされた人が指定されてなかったら見れない', async () => {
|
||||
test('[show] specified-mentionをされた人が指定されてなかったら見れない', async () => {
|
||||
const res = await show(speM.id, target2);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-mentionをフォロワーが見れない', async () => {
|
||||
test('[show] specified-mentionをフォロワーが見れない', async () => {
|
||||
const res = await show(speM.id, follower);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-mentionを非フォロワーが見れない', async () => {
|
||||
test('[show] specified-mentionを非フォロワーが見れない', async () => {
|
||||
const res = await show(speM.id, other);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
|
||||
it('[show] specified-mentionを未認証が見れない', async () => {
|
||||
test('[show] specified-mentionを未認証が見れない', async () => {
|
||||
const res = await show(speM.id, null);
|
||||
assert.strictEqual(res.body.isHidden, true);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region HTL
|
||||
it('[HTL] public-post が 自分が見れる', async () => {
|
||||
test('[HTL] public-post が 自分が見れる', async () => {
|
||||
const res = await request('/notes/timeline', { limit: 100 }, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === pub.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
});
|
||||
|
||||
it('[HTL] public-post が 非フォロワーから見れない', async () => {
|
||||
test('[HTL] public-post が 非フォロワーから見れない', async () => {
|
||||
const res = await request('/notes/timeline', { limit: 100 }, other);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === pub.id);
|
||||
assert.strictEqual(notes.length, 0);
|
||||
});
|
||||
|
||||
it('[HTL] followers-post が フォロワーから見れる', async () => {
|
||||
test('[HTL] followers-post が フォロワーから見れる', async () => {
|
||||
const res = await request('/notes/timeline', { limit: 100 }, follower);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === fol.id);
|
||||
@@ -435,21 +435,21 @@ describe('API visibility', () => {
|
||||
//#endregion
|
||||
|
||||
//#region RTL
|
||||
it('[replies] followers-reply が フォロワーから見れる', async () => {
|
||||
test('[replies] followers-reply が フォロワーから見れる', async () => {
|
||||
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === folR.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
});
|
||||
|
||||
it('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
|
||||
test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
|
||||
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === folR.id);
|
||||
assert.strictEqual(notes.length, 0);
|
||||
});
|
||||
|
||||
it('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
|
||||
test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
|
||||
const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === folR.id);
|
||||
@@ -458,14 +458,14 @@ describe('API visibility', () => {
|
||||
//#endregion
|
||||
|
||||
//#region MTL
|
||||
it('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
|
||||
test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
|
||||
const res = await request('/notes/mentions', { limit: 100 }, target);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === folR.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
});
|
||||
|
||||
it('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
|
||||
test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
|
||||
const res = await request('/notes/mentions', { limit: 100 }, target);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter((n: any) => n.id === folM.id);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user