Compare commits
167 commits
upstream-d
...
master
Author | SHA1 | Date | |
---|---|---|---|
ef46005e84 | |||
2ecd0dd1de | |||
2775328777 | |||
96bba9d47d | |||
6671562ab7 | |||
373eca7a19 | |||
70d5c713ca | |||
f3eeb711a0 | |||
80f788c38b | |||
1192cffa29 | |||
a3a6d2b5ba | |||
756c8b3ef4 | |||
c2029ed271 | |||
82c80a53a6 | |||
4b96e03f54 | |||
d591282f5e | |||
|
873ef89e42 | ||
d7a8660952 | |||
eec5ce1a99 | |||
f7cdb9df70 | |||
2b1c4b7245 | |||
e885beaab9 | |||
d25fa27c24 | |||
7a0067460b | |||
63a98f3b41 | |||
b29f49fefc | |||
|
8e508b921c | ||
9052a02598 | |||
57c4fef275 | |||
748685e53e | |||
8212c62663 | |||
8d48909e4f | |||
5587de26c7 | |||
|
9bb310e0d1 | ||
d621657f16 | |||
b33a595b67 | |||
|
ce08d2c827 | ||
|
a3ad95c058 | ||
|
3b804799c3 | ||
|
323de25075 | ||
|
c427e10f17 | ||
|
329995f4a3 | ||
|
f0a754eaa8 | ||
|
504ead526a | ||
15e669d943 | |||
e01e82aa65 | |||
a72ca7dcf4 | |||
599c265530 | |||
a97b5921c9 | |||
5b6e8cc110 | |||
a2517d3d03 | |||
9b8d02d1c3 | |||
7a7aef71cd | |||
95d3fb08f4 | |||
4ba0357d49 | |||
416d71002a | |||
a236bbb8d4 | |||
3d3bfad5d0 | |||
8a26ab9fb5 | |||
fe3fd951ed | |||
5673f0fff6 | |||
ccf81b9398 | |||
1025d5a4c4 | |||
f0c0b75285 | |||
4b6ec86a00 | |||
677a2f0770 | |||
|
9899b3cc38 | ||
6fab3e6087 | |||
31f19bd686 | |||
b78edccd19 | |||
b805239b39 | |||
f9167b8b86 | |||
d7080be847 | |||
935efb5bf2 | |||
c22160aa90 | |||
3906efa8d5 | |||
577a7e5e96 | |||
109d8f8008 | |||
4d44adfaa9 | |||
13e50cd8d9 | |||
a95e3716ff | |||
1862b33aba | |||
a6b90b14b6 | |||
c830e9ba0d | |||
d3674705a5 | |||
18c5d5cd02 | |||
237ac7cffc | |||
0ac3544a5c | |||
18d5587e5c | |||
60eb7e1dc9 | |||
738877016c | |||
0f90b37b62 | |||
52d0100918 | |||
fd271ea268 | |||
afac979977 | |||
073c70d42b | |||
174c3ef096 | |||
7748ab5dd0 | |||
2419a9f740 | |||
3fcea3eeb6 | |||
67197fbb76 | |||
e75d885a2c | |||
77b333dbdb | |||
82f4277673 | |||
7a106a390d | |||
24b6849ad1 | |||
6fda31bc88 | |||
18a27e3c47 | |||
adca3c686e | |||
94ce0fd414 | |||
39ae9c8f8a | |||
48b935e88c | |||
8652fd27b9 | |||
dc24536a7c | |||
997c5b3d35 | |||
|
e74174dea3 | ||
ffd5c9066d | |||
6f53315af1 | |||
5aef6a6238 | |||
ae46750102 | |||
972e5ef753 | |||
539ce47adb | |||
770226a040 | |||
8e9c175c06 | |||
616ec50435 | |||
c154154645 | |||
3d2f7976ba | |||
08a38e0d0b | |||
235cb0af15 | |||
a7421ca279 | |||
5125e98699 | |||
b6c7c0886a | |||
283738d5e7 | |||
858800ad2d | |||
28a5dcfa31 | |||
b99cf702a0 | |||
6dbc02af9a | |||
7f071473b9 | |||
eb4ccbb662 | |||
6411e65989 | |||
|
514e7683c0 | ||
6bd7a8ae63 | |||
a59688fab4 | |||
44152f876d | |||
80209385e8 | |||
93a9ba8159 | |||
fe58cef568 | |||
6e7aee287a | |||
411cb8351c | |||
93a45a5779 | |||
4bc97c75c7 | |||
e1ebf46071 | |||
6c9be945a6 | |||
c06809c93e | |||
9a75e2f53b | |||
acba0bb54c | |||
ec060b7a14 | |||
|
0bc96fb197 | ||
086532218e | |||
fb690169f3 | |||
f472f66eea | |||
393487a98a | |||
5b9d4cbe01 | |||
c8426dca0e | |||
deb2229dd7 | |||
|
b99e13e667 | ||
|
2518cf36d0 |
176 changed files with 8920 additions and 1614 deletions
|
@ -153,6 +153,13 @@ redis:
|
||||||
|
|
||||||
id: 'aidx'
|
id: 'aidx'
|
||||||
|
|
||||||
|
# ┌──────────┐
|
||||||
|
#───┘ Metrics └──────────────────────────────────────────
|
||||||
|
|
||||||
|
#prometheusMetrics:
|
||||||
|
# enable: false
|
||||||
|
# scrapeToken: '' # Set non-empty to require a bearer token for scraping
|
||||||
|
|
||||||
# ┌────────────────┐
|
# ┌────────────────┐
|
||||||
#───┘ Error tracking └──────────────────────────────────────────
|
#───┘ Error tracking └──────────────────────────────────────────
|
||||||
|
|
||||||
|
@ -168,12 +175,36 @@ id: 'aidx'
|
||||||
# options:
|
# options:
|
||||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||||
|
|
||||||
# ┌─────────────────────┐
|
# ┌──────────────┐
|
||||||
#───┘ Other configuration └─────────────────────────────────────
|
#──┘ Web Security └──────────────────────────────────────
|
||||||
|
|
||||||
# Whether disable HSTS
|
# Whether disable HSTS
|
||||||
#disableHsts: true
|
#disableHsts: true
|
||||||
|
|
||||||
|
# Whether to enable HSTS preload
|
||||||
|
# Read these before enabling:
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#preloading_strict_transport_security
|
||||||
|
# - https://hstspreload.org/
|
||||||
|
#hstsPreload: false
|
||||||
|
|
||||||
|
# Enable additional security headers that reduce the risk of XSS attacks or privacy leaks.
|
||||||
|
# browserSandboxing:
|
||||||
|
# # Do not send the Referrer header to other domains. The default when browserSandboxing is missing is true.
|
||||||
|
# strictOriginReferrer: true
|
||||||
|
# csp:
|
||||||
|
# # Do not send a CSP header. The default is a strict CSP header that prevents any form of external fetching or execution.
|
||||||
|
# disable: false
|
||||||
|
# # Merge additional directives into the CSP header. The default is an empty object.
|
||||||
|
# # You may want to list your CDN or other trusted domains here.
|
||||||
|
# # Media proxies are automatically added to the CSP header. This is an exception, things like Sentry will not be automatically added.
|
||||||
|
# appendDirectives:
|
||||||
|
# 'script-src':
|
||||||
|
# - "'unsafe-eval'" # do not use this ... just an example
|
||||||
|
# - 'https://example.com'
|
||||||
|
|
||||||
|
# ┌─────────────────────┐
|
||||||
|
#───┘ Other configuration └─────────────────────────────────────
|
||||||
|
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
|
|
@ -147,6 +147,13 @@ redis:
|
||||||
|
|
||||||
id: 'aidx'
|
id: 'aidx'
|
||||||
|
|
||||||
|
# ┌──────────┐
|
||||||
|
#───┘ Metrics └──────────────────────────────────────────
|
||||||
|
|
||||||
|
#prometheusMetrics:
|
||||||
|
# enable: false
|
||||||
|
# scrapeToken: '' # Set non-empty to require a bearer token for scraping
|
||||||
|
|
||||||
# ┌────────────────┐
|
# ┌────────────────┐
|
||||||
#───┘ Error tracking └──────────────────────────────────────────
|
#───┘ Error tracking └──────────────────────────────────────────
|
||||||
|
|
||||||
|
@ -162,12 +169,36 @@ id: 'aidx'
|
||||||
# options:
|
# options:
|
||||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||||
|
|
||||||
# ┌─────────────────────┐
|
# ┌──────────────┐
|
||||||
#───┘ Other configuration └─────────────────────────────────────
|
#──┘ Web Security └──────────────────────────────────────
|
||||||
|
|
||||||
# Whether disable HSTS
|
# Whether disable HSTS
|
||||||
#disableHsts: true
|
#disableHsts: true
|
||||||
|
|
||||||
|
# Whether to enable HSTS preload
|
||||||
|
# Read these before enabling:
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#preloading_strict_transport_security
|
||||||
|
# - https://hstspreload.org/
|
||||||
|
#hstsPreload: false
|
||||||
|
|
||||||
|
# Enable additional security headers that reduce the risk of XSS attacks or privacy leaks.
|
||||||
|
# browserSandboxing:
|
||||||
|
# # Do not send the Referrer header to other domains. The default when browserSandboxing is missing is true.
|
||||||
|
# strictOriginReferrer: true
|
||||||
|
# csp:
|
||||||
|
# # Do not send a CSP header. The default is a strict CSP header that prevents any form of external fetching or execution.
|
||||||
|
# disable: false
|
||||||
|
# # Merge additional directives into the CSP header. The default is an empty object.
|
||||||
|
# # You may want to list your CDN or other trusted domains here.
|
||||||
|
# # Media proxies are automatically added to the CSP header. This is an exception, things like Sentry will not be automatically added.
|
||||||
|
# appendDirectives:
|
||||||
|
# 'script-src':
|
||||||
|
# - "'unsafe-eval'" # do not use this ... just an example
|
||||||
|
# - 'https://example.com'
|
||||||
|
|
||||||
|
# ┌─────────────────────┐
|
||||||
|
#───┘ Other configuration └─────────────────────────────────────
|
||||||
|
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
|
|
@ -229,6 +229,13 @@ redis:
|
||||||
|
|
||||||
id: 'aidx'
|
id: 'aidx'
|
||||||
|
|
||||||
|
# ┌──────────┐
|
||||||
|
#───┘ Metrics └──────────────────────────────────────────
|
||||||
|
|
||||||
|
#prometheusMetrics:
|
||||||
|
# enable: false
|
||||||
|
# scrapeToken: '' # Set non-empty to require a bearer token for scraping
|
||||||
|
|
||||||
# ┌────────────────┐
|
# ┌────────────────┐
|
||||||
#───┘ Error tracking └──────────────────────────────────────────
|
#───┘ Error tracking └──────────────────────────────────────────
|
||||||
|
|
||||||
|
@ -244,12 +251,36 @@ id: 'aidx'
|
||||||
# options:
|
# options:
|
||||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||||
|
|
||||||
# ┌─────────────────────┐
|
# ┌──────────────┐
|
||||||
#───┘ Other configuration └─────────────────────────────────────
|
#──┘ Web Security └──────────────────────────────────────
|
||||||
|
|
||||||
# Whether disable HSTS
|
# Whether disable HSTS
|
||||||
#disableHsts: true
|
#disableHsts: true
|
||||||
|
|
||||||
|
# Whether to enable HSTS preload
|
||||||
|
# Read these before enabling:
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#preloading_strict_transport_security
|
||||||
|
# - https://hstspreload.org/
|
||||||
|
#hstsPreload: false
|
||||||
|
|
||||||
|
# Enable additional security headers that reduce the risk of XSS attacks or privacy leaks.
|
||||||
|
# browserSandboxing:
|
||||||
|
# # Do not send the Referrer header to other domains. The default when browserSandboxing is missing is true.
|
||||||
|
# strictOriginReferrer: true
|
||||||
|
# csp:
|
||||||
|
# # Do not send a CSP header. The default is a strict CSP header that prevents any form of external fetching or execution.
|
||||||
|
# disable: false
|
||||||
|
# # Merge additional directives into the CSP header. The default is an empty object.
|
||||||
|
# # You may want to list your CDN or other trusted domains here.
|
||||||
|
# # Media proxies are automatically added to the CSP header. This is an exception, things like Sentry will not be automatically added.
|
||||||
|
# appendDirectives:
|
||||||
|
# 'script-src':
|
||||||
|
# - "'unsafe-eval'" # do not use this ... just an example
|
||||||
|
# - 'https://example.com'
|
||||||
|
|
||||||
|
# ┌─────────────────────┐
|
||||||
|
#───┘ Other configuration └─────────────────────────────────────
|
||||||
|
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
|
|
@ -161,6 +161,12 @@ id: 'aidx'
|
||||||
# Whether disable HSTS
|
# Whether disable HSTS
|
||||||
#disableHsts: true
|
#disableHsts: true
|
||||||
|
|
||||||
|
# Whether to enable HSTS preload
|
||||||
|
# Read these before enabling:
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#preloading_strict_transport_security
|
||||||
|
# - https://hstspreload.org/
|
||||||
|
#hstsPreload: false
|
||||||
|
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
.autogen
|
.autogen
|
||||||
.github
|
.github
|
||||||
|
.forgejo
|
||||||
.travis
|
.travis
|
||||||
.vscode
|
.vscode
|
||||||
.config
|
.config
|
||||||
|
|
||||||
Dockerfile
|
Dockerfile
|
||||||
build/
|
build/
|
||||||
built/
|
built/
|
||||||
|
@ -28,3 +30,5 @@ fluent-emojis/
|
||||||
.idea/
|
.idea/
|
||||||
packages/*/.vscode/
|
packages/*/.vscode/
|
||||||
packages/backend/test/compose.yml
|
packages/backend/test/compose.yml
|
||||||
|
|
||||||
|
/yume-mods
|
||||||
|
|
17
.forgejo/misskey/test.yml
Normal file
17
.forgejo/misskey/test.yml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
url: 'http://misskey.local'
|
||||||
|
|
||||||
|
setupPassword: example_password_please_change_this_or_you_will_get_hacked
|
||||||
|
|
||||||
|
# ローカルでテストするときにポートを被らないようにするためデフォルトのものとは変える(以下同じ)
|
||||||
|
port: 61812
|
||||||
|
|
||||||
|
db:
|
||||||
|
host: postgres
|
||||||
|
port: 5432
|
||||||
|
db: test-misskey
|
||||||
|
user: postgres
|
||||||
|
pass: ''
|
||||||
|
redis:
|
||||||
|
host: redis
|
||||||
|
port: 6379
|
||||||
|
id: aidx
|
60
.forgejo/workflows/docker.yml
Normal file
60
.forgejo/workflows/docker.yml
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_IMAGE: l1drm/yumechi-no-kuni
|
||||||
|
TAGS: |
|
||||||
|
type=edge
|
||||||
|
type=ref,event=pr
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# see https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform:
|
||||||
|
- linux/amd64
|
||||||
|
steps:
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4.1.1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_IMAGE }}
|
||||||
|
tags: ${{ env.TAGS }}
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build and Push to Docker Hub
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
provenance: true
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
111
.forgejo/workflows/lint.yml
Normal file
111
.forgejo/workflows/lint.yml
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
- packages/frontend/**
|
||||||
|
- packages/frontend-shared/**
|
||||||
|
- packages/frontend-embed/**
|
||||||
|
- packages/sw/**
|
||||||
|
- packages/misskey-js/**
|
||||||
|
- packages/misskey-bubble-game/**
|
||||||
|
- packages/misskey-reversi/**
|
||||||
|
- packages/shared/eslint.config.js
|
||||||
|
- .forgejo/workflows/lint.yml
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
- packages/frontend/**
|
||||||
|
- packages/frontend-shared/**
|
||||||
|
- packages/frontend-embed/**
|
||||||
|
- packages/sw/**
|
||||||
|
- packages/misskey-js/**
|
||||||
|
- packages/misskey-bubble-game/**
|
||||||
|
- packages/misskey-reversi/**
|
||||||
|
- packages/shared/eslint.config.js
|
||||||
|
- .forgejo/workflows/lint.yml
|
||||||
|
jobs:
|
||||||
|
pnpm_install:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4.0.4
|
||||||
|
with:
|
||||||
|
node-version-file: '.node-version'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
lint:
|
||||||
|
needs: [pnpm_install]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
workspace:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
- frontend-shared
|
||||||
|
- frontend-embed
|
||||||
|
- sw
|
||||||
|
- misskey-js
|
||||||
|
- misskey-bubble-game
|
||||||
|
- misskey-reversi
|
||||||
|
env:
|
||||||
|
eslint-cache-version: v1
|
||||||
|
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4.0.4
|
||||||
|
with:
|
||||||
|
node-version-file: '.node-version'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
- name: Restore eslint cache
|
||||||
|
uses: actions/cache@v4.1.0
|
||||||
|
with:
|
||||||
|
path: ${{ env.eslint-cache-path }}
|
||||||
|
key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
|
||||||
|
restore-keys: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||||
|
- run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location ${{ env.eslint-cache-path }} --cache-strategy content
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
needs: [pnpm_install]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
workspace:
|
||||||
|
- backend
|
||||||
|
- sw
|
||||||
|
- misskey-js
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4.0.4
|
||||||
|
with:
|
||||||
|
node-version-file: '.node-version'
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
- run: pnpm --filter misskey-js run build
|
||||||
|
if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }}
|
||||||
|
- run: pnpm --filter misskey-reversi run build
|
||||||
|
if: ${{ matrix.workspace == 'backend' }}
|
||||||
|
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
|
98
.forgejo/workflows/test-backend.yml
Normal file
98
.forgejo/workflows/test-backend.yml
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
name: Test (backend)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
# for permissions
|
||||||
|
- packages/misskey-js/**
|
||||||
|
- .forgejo/workflows/test-backend.yml
|
||||||
|
- .forgejo/misskey/**
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packages/backend/**
|
||||||
|
# for permissions
|
||||||
|
- packages/misskey-js/**
|
||||||
|
- .forgejo/workflows/test-backend.yml
|
||||||
|
- .forgejo/misskey/**
|
||||||
|
jobs:
|
||||||
|
unit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [22.11.0]
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: l1drm/postgres-pgroonga:alpine-15-znver4
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: test-misskey
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
- name: Install FFmpeg
|
||||||
|
uses: https://github.com/FedericoCarboni/setup-ffmpeg@v3
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4.0.4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
- name: Check pnpm-lock.yaml
|
||||||
|
run: git diff --exit-code pnpm-lock.yaml
|
||||||
|
- name: Copy Configure
|
||||||
|
run: cp .forgejo/misskey/test.yml .config
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
- name: Test
|
||||||
|
run: pnpm --filter backend test-and-coverage
|
||||||
|
|
||||||
|
# e2e:
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
#
|
||||||
|
# strategy:
|
||||||
|
# matrix:
|
||||||
|
# node-version: [22.11.0]
|
||||||
|
#
|
||||||
|
# services:
|
||||||
|
# postgres:
|
||||||
|
# image: l1drm/postgres-pgroonga:alpine-15-znver4
|
||||||
|
# env:
|
||||||
|
# POSTGRES_DB: test-misskey
|
||||||
|
# POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
# redis:
|
||||||
|
# image: redis:7
|
||||||
|
#
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v4.1.1
|
||||||
|
# with:
|
||||||
|
# submodules: true
|
||||||
|
# - name: Install pnpm
|
||||||
|
# uses: pnpm/action-setup@v4
|
||||||
|
# - name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
# uses: actions/setup-node@v4.0.4
|
||||||
|
# with:
|
||||||
|
# node-version: ${{ matrix.node-version }}
|
||||||
|
# cache: 'pnpm'
|
||||||
|
# - run: corepack enable
|
||||||
|
# - run: pnpm i --frozen-lockfile
|
||||||
|
# - name: Check pnpm-lock.yaml
|
||||||
|
# run: git diff --exit-code pnpm-lock.yaml
|
||||||
|
# - name: Copy Configure
|
||||||
|
# run: cp .forgejo/misskey/test.yml .config
|
||||||
|
# - name: Build
|
||||||
|
# run: pnpm build
|
||||||
|
# - name: Test
|
||||||
|
# run: pnpm --filter backend test-and-coverage:e2e
|
39
.forgejo/workflows/test-production.yml
Normal file
39
.forgejo/workflows/test-production.yml
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
name: Test (production install and build)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [22.11.0]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.1.1
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4.0.4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
- name: Check pnpm-lock.yaml
|
||||||
|
run: git diff --exit-code pnpm-lock.yaml
|
||||||
|
- name: Copy Configure
|
||||||
|
run: cp .github/misskey/test.yml .config/default.yml
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
97
.github/ISSUE_TEMPLATE/01_bug-report.yml
vendored
97
.github/ISSUE_TEMPLATE/01_bug-report.yml
vendored
|
@ -1,97 +0,0 @@
|
||||||
name: 🐛 Bug Report
|
|
||||||
description: Create a report to help us improve
|
|
||||||
labels: ["⚠️bug?"]
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for reporting!
|
|
||||||
First, in order to avoid duplicate Issues, please search to see if the problem you found has already been reported.
|
|
||||||
Also, If you are NOT owner/admin of server, PLEASE DONT REPORT SERVER SPECIFIC ISSUES TO HERE! (e.g. feature XXX is not working in misskey.example) Please try with another misskey servers, and if your issue is only reproducible with specific server, contact your server's owner/admin first.
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 💡 Summary
|
|
||||||
description: Tell us what the bug is
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 🥰 Expected Behavior
|
|
||||||
description: Tell us what should happen
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 🤬 Actual Behavior
|
|
||||||
description: |
|
|
||||||
Tell us what happens instead of the expected behavior.
|
|
||||||
Please include errors from the developer console and/or server log files if you have access to them.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 📝 Steps to Reproduce
|
|
||||||
placeholder: |
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 💻 Frontend Environment
|
|
||||||
description: |
|
|
||||||
Tell us where on the platform it happens
|
|
||||||
DO NOT WRITE "latest". Please provide the specific version.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
* Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4
|
|
||||||
* Browser: Chrome 113.0.5672.126
|
|
||||||
* Server URL: misskey.example.com
|
|
||||||
* Misskey: 2024.x.x
|
|
||||||
value: |
|
|
||||||
* Model and OS of the device(s):
|
|
||||||
* Browser:
|
|
||||||
* Server URL:
|
|
||||||
* Misskey:
|
|
||||||
render: markdown
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 🛰 Backend Environment (for server admin)
|
|
||||||
description: |
|
|
||||||
Tell us where on the platform it happens
|
|
||||||
DO NOT WRITE "latest". Please provide the specific version.
|
|
||||||
If you are using a managed service, put that after the version.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
|
|
||||||
* Misskey: 2024.x.x
|
|
||||||
* Node: 20.x.x
|
|
||||||
* PostgreSQL: 15.x.x
|
|
||||||
* Redis: 7.x.x
|
|
||||||
* OS and Architecture: Ubuntu 24.04.2 LTS aarch64
|
|
||||||
value: |
|
|
||||||
* Installation Method or Hosting Service:
|
|
||||||
* Misskey:
|
|
||||||
* Node:
|
|
||||||
* PostgreSQL:
|
|
||||||
* Redis:
|
|
||||||
* OS and Architecture:
|
|
||||||
render: markdown
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Do you want to address this bug yourself?
|
|
||||||
options:
|
|
||||||
- label: Yes, I will patch the bug myself and send a pull request
|
|
22
.github/ISSUE_TEMPLATE/02_feature-request.yml
vendored
22
.github/ISSUE_TEMPLATE/02_feature-request.yml
vendored
|
@ -1,22 +0,0 @@
|
||||||
name: ✨ Feature Request
|
|
||||||
description: Suggest an idea for this project
|
|
||||||
labels: ["✨Feature"]
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Summary
|
|
||||||
description: Tell us what the suggestion is
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Purpose
|
|
||||||
description: Describe the specific problem or need you think this feature will solve, and who it will help.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Do you want to implement this feature yourself?
|
|
||||||
options:
|
|
||||||
- label: Yes, I will implement this by myself and send a pull request
|
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
|
@ -1,8 +0,0 @@
|
||||||
contact_links:
|
|
||||||
- name: 💬 Misskey official Discord
|
|
||||||
url: https://discord.gg/Wp8gVStHW3
|
|
||||||
about: Chat freely about Misskey
|
|
||||||
# 仮
|
|
||||||
- name: 💬 Start discussion
|
|
||||||
url: https://github.com/misskey-dev/misskey/discussions
|
|
||||||
about: The official forum to join conversation and ask question
|
|
23
.github/PULL_REQUEST_TEMPLATE/01_bug.md
vendored
23
.github/PULL_REQUEST_TEMPLATE/01_bug.md
vendored
|
@ -1,23 +0,0 @@
|
||||||
<!-- ℹ お読みください / README
|
|
||||||
PRありがとうございます! PRを作成する前に、コントリビューションガイドをご確認ください:
|
|
||||||
Thank you for your PR! Before creating a PR, please check the contribution guide:
|
|
||||||
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
|
|
||||||
-->
|
|
||||||
|
|
||||||
## What
|
|
||||||
<!-- このPRで何をしたのか? どう変わるのか? -->
|
|
||||||
<!-- What did you do with this PR? How will it change things? -->
|
|
||||||
|
|
||||||
## Why
|
|
||||||
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
|
|
||||||
<!-- Why do you do it? What are your intentions? What is the problem? -->
|
|
||||||
|
|
||||||
## Additional info (optional)
|
|
||||||
<!-- テスト観点など -->
|
|
||||||
<!-- Test perspective, etc -->
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md)
|
|
||||||
- [ ] Test working in a local environment
|
|
||||||
- [ ] (If needed) Update CHANGELOG.md
|
|
||||||
- [ ] (If possible) Add tests
|
|
23
.github/PULL_REQUEST_TEMPLATE/02_enhance.md
vendored
23
.github/PULL_REQUEST_TEMPLATE/02_enhance.md
vendored
|
@ -1,23 +0,0 @@
|
||||||
<!-- ℹ お読みください / README
|
|
||||||
PRありがとうございます! PRを作成する前に、コントリビューションガイドをご確認ください:
|
|
||||||
Thank you for your PR! Before creating a PR, please check the contribution guide:
|
|
||||||
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
|
|
||||||
-->
|
|
||||||
|
|
||||||
## What
|
|
||||||
<!-- このPRで何をしたのか? どう変わるのか? -->
|
|
||||||
<!-- What did you do with this PR? How will it change things? -->
|
|
||||||
|
|
||||||
## Why
|
|
||||||
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
|
|
||||||
<!-- Why do you do it? What are your intentions? What is the problem? -->
|
|
||||||
|
|
||||||
## Additional info (optional)
|
|
||||||
<!-- テスト観点など -->
|
|
||||||
<!-- Test perspective, etc -->
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md)
|
|
||||||
- [ ] Test working in a local environment
|
|
||||||
- [ ] (If needed) Update CHANGELOG.md
|
|
||||||
- [ ] (If possible) Add tests
|
|
20
.github/PULL_REQUEST_TEMPLATE/03_release.md
vendored
20
.github/PULL_REQUEST_TEMPLATE/03_release.md
vendored
|
@ -1,20 +0,0 @@
|
||||||
## Summary
|
|
||||||
This is a release PR.
|
|
||||||
|
|
||||||
For more information on the release instructions, please see:
|
|
||||||
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md#release
|
|
||||||
|
|
||||||
## For reviewers
|
|
||||||
- CHANGELOGに抜け漏れは無いか
|
|
||||||
- バージョンの上げ方は適切か
|
|
||||||
- 他にこのリリースに含めなければならない変更は無いか
|
|
||||||
- 全体的な変更内容を俯瞰し問題は無いか
|
|
||||||
- レビューされていないコミットがある場合は、それが問題ないか
|
|
||||||
- 最終的な動作確認を行い問題は無いか
|
|
||||||
|
|
||||||
などを確認し、リリースする準備が整っていると思われる場合は approve してください。
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] package.jsonのバージョンが正しく更新されている
|
|
||||||
- [ ] CHANGELOGが過不足無く更新されている
|
|
||||||
- [ ] CIが全て通っている
|
|
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
|
@ -1,7 +1,7 @@
|
||||||
<!-- ℹ お読みください / README
|
<!-- ℹ お読みください / README
|
||||||
PRありがとうございます! PRを作成する前に、コントリビューションガイドをご確認ください:
|
PRありがとうございます! PRを作成する前に、コントリビューションガイドをご確認ください:
|
||||||
Thank you for your PR! Before creating a PR, please check the contribution guide:
|
Thank you for your PR! Before creating a PR, please check the contribution guide:
|
||||||
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
|
https://forge.yumechi.jp/yume/yumechi-no-kuni/src/branch/master/CONTRIBUTING.md
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
@ -17,7 +17,7 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
|
||||||
<!-- Test perspective, etc -->
|
<!-- Test perspective, etc -->
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md)
|
- [ ] Read the [contribution guide](https://forge.yumechi.jp/yume/yumechi-no-kuni/src/branch/master/CONTRIBUTING.md)
|
||||||
- [ ] Test working in a local environment
|
- [ ] Test working in a local environment
|
||||||
- [ ] (If needed) Add story of storybook
|
- [ ] (If needed) Add story of storybook
|
||||||
- [ ] (If needed) Update CHANGELOG.md
|
- [ ] (If needed) Update CHANGELOG.md
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
build:
|
|
||||||
misskey:
|
|
||||||
args:
|
|
||||||
- NODE_ENV=development
|
|
||||||
deploy:
|
|
||||||
- helm upgrade --install misskey chart --set image=${OKTETO_BUILD_MISSKEY_IMAGE} --set url="https://misskey-$(kubectl config view --minify -o jsonpath='{..namespace}').cloud.okteto.net" --set environment=development
|
|
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
@ -11,5 +11,9 @@
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit"
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": false
|
"editor.formatOnSave": false,
|
||||||
|
"rust-analyzer.linkedProjects": [
|
||||||
|
"yume-mods/nyuukyou/Cargo.toml",
|
||||||
|
"yume-mods/misskey-auto-deploy/Cargo.toml",
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
49
CHANGELOG.md
49
CHANGELOG.md
|
@ -1,3 +1,51 @@
|
||||||
|
|
||||||
|
## 2024.11.0-yumechinokuni.8
|
||||||
|
|
||||||
|
- Frontend: SSRでユーザープロフィールが表示されない問題を修正
|
||||||
|
- Security: SSRプライバシー方面の改善
|
||||||
|
- Security: AP Payloadの検証を強化
|
||||||
|
|
||||||
|
## 2024.11.0-yumechinokuni.7
|
||||||
|
|
||||||
|
- Misskey Trademark内容をWebUIから削除
|
||||||
|
- Service Worker キャッシュが正しく動作しない問題を修正
|
||||||
|
|
||||||
|
## 2024.11.0-yumechinokuni.6
|
||||||
|
|
||||||
|
- Upstream: 2024.11.0-alpha.4 タッグをマージする
|
||||||
|
- Performance: EmojiのリクエストをProxyでキャッシュするように
|
||||||
|
- Performance: Service Workerのキャッシュを最適化
|
||||||
|
- Security: AP Payloadの検証を強化
|
||||||
|
- Security: Image/Video Processorはドライブ機能だけを使うように
|
||||||
|
|
||||||
|
## 2024.11.0-yumechinokuni.5
|
||||||
|
|
||||||
|
- Upstream: 2024.11.0-alpha.2 タッグをマージする
|
||||||
|
- Reliability: Activitypub event deduplication
|
||||||
|
- DevOps: Prometheus サーバーメトリクス
|
||||||
|
- Enhance: ハッシュタグランギングを改善
|
||||||
|
- Enhance: PgroongaのCWサーチ + パフォーマンス改善
|
||||||
|
|
||||||
|
## 2024.11.0-yumechinokuni.4
|
||||||
|
|
||||||
|
- Upstream: 2024.11.0-alpha.1 タッグをマージする
|
||||||
|
- DevOps: 管理者アクセストークンがユーザー登録できるようになる (write:admin:create-account)
|
||||||
|
- Frontend: Stream再接続ロジックdata raceを修正
|
||||||
|
- Security: CSPにCDNなどの外部ホストはホワイトリストできるように
|
||||||
|
|
||||||
|
### 2024.11.0-yumechinokuni.4p1
|
||||||
|
|
||||||
|
PgroongaのCWサーチ (github.com/paricafe/misskey#d30db97b59d264450901c1dd86808dcb43875ea9)
|
||||||
|
|
||||||
|
### 2024.11.0-yumechinokuni.4p2
|
||||||
|
|
||||||
|
- fix(backend): アナウンスメントを作成ときにWebUIフォームの画像URLを後悔できないのを修正 (/admin/announcement/create)
|
||||||
|
|
||||||
|
## 2024.11.0-yumechinokuni.3
|
||||||
|
|
||||||
|
- Security: CSPの設定を強化
|
||||||
|
- Fix: flaky testの修正
|
||||||
|
|
||||||
## 2024.11.0
|
## 2024.11.0
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
@ -30,6 +78,7 @@
|
||||||
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
|
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
|
||||||
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
|
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
|
||||||
- Enhance: リノートメニューに「リノートの詳細」を追加
|
- Enhance: リノートメニューに「リノートの詳細」を追加
|
||||||
|
- Enhance: 非ログイン状態でMisskeyを開いた際のパフォーマンスを向上
|
||||||
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
|
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
|
||||||
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
|
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
|
||||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
|
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
|
||||||
|
|
62
README.md
62
README.md
|
@ -1,49 +1,31 @@
|
||||||
<div align="center">
|
# ゆめちのくに
|
||||||
<a href="https://misskey-hub.net">
|
|
||||||
<img src="./assets/title_float.svg" alt="Misskey logo" style="border-radius:50%" width="300"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
**🌎 **Misskey** is an open source, federated social media platform that's free forever! 🚀**
|
YumechiNoKuni is a fork of Misskey, with a focus on security, observability and reliability.
|
||||||
|
|
||||||
[Learn more](https://misskey-hub.net/)
|
[mi.yumechi.jp](https://mi.yumechi.jp) is running this version.
|
||||||
|
|
||||||
---
|
[Learn more about Misskey](https://misskey-hub.net/)
|
||||||
|
|
||||||
<a href="https://misskey-hub.net/servers/">
|
## Main differences
|
||||||
<img src="https://custom-icon-badges.herokuapp.com/badge/find_an-instance-acea31?logoColor=acea31&style=for-the-badge&logo=misskey&labelColor=363B40" alt="find an instance"/></a>
|
|
||||||
|
|
||||||
<a href="https://misskey-hub.net/docs/for-admin/install/guides/">
|
### Unique features
|
||||||
<img src="https://custom-icon-badges.herokuapp.com/badge/create_an-instance-FBD53C?logoColor=FBD53C&style=for-the-badge&logo=server&labelColor=363B40" alt="create an instance"/></a>
|
|
||||||
|
|
||||||
<a href="./CONTRIBUTING.md">
|
- Strict ActivityPub sanitization by whitelisting properties and normalizing all referential properties.
|
||||||
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-contributor-A371F7?logoColor=A371F7&style=for-the-badge&logo=git-merge&labelColor=363B40" alt="become a contributor"/></a>
|
- Strict Content Security Policy.
|
||||||
|
- Require TLSv1.2+ over port 443 for all ActivityPub requests.
|
||||||
|
- Strongly-typed inbox filtering in Rust.
|
||||||
|
- Reduce needless retries by marking more errors as permanent.
|
||||||
|
- Detailed prometheus metrics for slow requests, DB queries, AP processing, failed auths, etc.
|
||||||
|
- Disable unauthenticated media processing and use custom AppArmored media proxy.
|
||||||
|
- Enable active users in nodeinfo back.
|
||||||
|
- Advertise Git information over nodeinfo for better observability and easy tracking of the actual code running.
|
||||||
|
- Logical replication for the database over mTLS.
|
||||||
|
- More atomic operations in API handlers.
|
||||||
|
|
||||||
<a href="https://discord.gg/Wp8gVStHW3">
|
### Picked from github.com/paricafe/misskey
|
||||||
<img src="https://custom-icon-badges.herokuapp.com/badge/join_the-community-5865F2?logoColor=5865F2&style=for-the-badge&logo=discord&labelColor=363B40" alt="join the community"/></a>
|
|
||||||
|
|
||||||
<a href="https://www.patreon.com/syuilo">
|
- pgroonga full-text search (with modifications).
|
||||||
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a>
|
- Better Service Worker caching.
|
||||||
|
- Better hashtag statistics.
|
||||||
|
- Better handling of deep recursive AP objects.
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
<a href="https://sentry.io/"><img src="https://github.com/misskey-dev/misskey/assets/4439005/98576556-222f-467a-94be-e98dbda1d852" height="30" alt="Sentry" /></a>
|
|
||||||
|
|
||||||
Thanks to [Sentry](https://sentry.io/) for providing the error tracking platform that helps us catch unexpected errors.
|
|
||||||
|
|
||||||
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" height="30" alt="Chromatic" /></a>
|
|
||||||
|
|
||||||
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
|
|
||||||
|
|
||||||
<a href="https://about.codecov.io/for/open-source/"><img src="https://about.codecov.io/wp-content/themes/codecov/assets/brand/sentry-cobranding/logos/codecov-by-sentry-logo.svg" height="30" alt="Codecov" /></a>
|
|
||||||
|
|
||||||
Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage.
|
|
||||||
|
|
||||||
<a href="https://crowdin.com/"><img src="https://user-images.githubusercontent.com/20679825/230709597-1299a011-171a-4294-a91e-355a9b37c672.svg" height="30" alt="Crowdin" /></a>
|
|
||||||
|
|
||||||
Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages.
|
|
||||||
|
|
||||||
<a href="https://hub.docker.com/"><img src="https://user-images.githubusercontent.com/20679825/230148221-f8e73a32-a49b-47c3-9029-9a15c3824f92.png" height="30" alt="Docker" /></a>
|
|
||||||
|
|
||||||
Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production.
|
|
||||||
|
|
13
SECURITY.md
13
SECURITY.md
|
@ -1,15 +1,12 @@
|
||||||
# Reporting Security Issues
|
# Reporting Security Issues
|
||||||
|
|
||||||
If you discover a security issue in Misskey, please report it by **[this form](https://github.com/misskey-dev/misskey/security/advisories/new)**.
|
If you discover a security issue in this project, please use the `git blame` command to identify the source of the issue,
|
||||||
|
if it was introduced by this fork please contact me at secity<at>yumechi.jp.
|
||||||
|
|
||||||
This will allow us to assess the risk, and make a fix available before we add a
|
For upstream issues please report by **[this form](https://github.com/misskey-dev/misskey/security/advisories/new)**.
|
||||||
bug report to the GitHub repository.
|
|
||||||
|
|
||||||
Thanks for helping make Misskey safe for everyone.
|
Thanks for helping make YumechiNoKuni safe for everyone.
|
||||||
|
|
||||||
## When create a patch
|
## When create a patch
|
||||||
|
|
||||||
If you can also create a patch to fix the vulnerability, please create a PR on the private fork.
|
If you can also create a patch to fix the vulnerability, please send a diff file with the report.
|
||||||
|
|
||||||
> [!note]
|
|
||||||
> There is a GitHub bug that prevents merging if a PR not following the develop branch of upstream, so please keep follow the develop branch.
|
|
||||||
|
|
|
@ -182,6 +182,12 @@ id: "aidx"
|
||||||
# Whether disable HSTS
|
# Whether disable HSTS
|
||||||
#disableHsts: true
|
#disableHsts: true
|
||||||
|
|
||||||
|
# Whether to enable HSTS preload
|
||||||
|
# Read these before enabling:
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#preloading_strict_transport_security
|
||||||
|
# - https://hstspreload.org/
|
||||||
|
#hstsPreload: false
|
||||||
|
|
||||||
# Number of worker processes
|
# Number of worker processes
|
||||||
#clusterLimit: 1
|
#clusterLimit: 1
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ services:
|
||||||
|
|
||||||
db:
|
db:
|
||||||
restart: always
|
restart: always
|
||||||
image: postgres:15-alpine
|
image: l1drm/postgres-pgroonga:alpine-15-znver4
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
env_file:
|
env_file:
|
||||||
|
@ -27,6 +27,18 @@ services:
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
# db-publish:
|
||||||
|
# restart: always
|
||||||
|
# image: alpine/socat
|
||||||
|
# ports:
|
||||||
|
# - "5433:5433"
|
||||||
|
# volumes:
|
||||||
|
# - ./replication:/data
|
||||||
|
# command: [
|
||||||
|
# "OPENSSL-LISTEN:5433,openssl-min-proto-version=TLS1.2,cert=/data/certs/server.pub.pem,key=/data/certs/server.key.pem,cafile=/data/certs/ca.pem,fork,reuseaddr",
|
||||||
|
# "TCP4:db:5432"
|
||||||
|
# ]
|
||||||
|
|
||||||
# meilisearch:
|
# meilisearch:
|
||||||
# restart: always
|
# restart: always
|
||||||
# image: getmeili/meilisearch:v1.3.4
|
# image: getmeili/meilisearch:v1.3.4
|
||||||
|
|
|
@ -1,7 +1,31 @@
|
||||||
services:
|
services:
|
||||||
web:
|
nyuukyou:
|
||||||
build: .
|
build: yume-mods/nyuukyou
|
||||||
restart: always
|
restart: always
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
|
profiles: [web]
|
||||||
|
links:
|
||||||
|
- web
|
||||||
|
depends_on:
|
||||||
|
web:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal_network
|
||||||
|
- external_network
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./nyuukyou:/store
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- UID: "${MISSKEY_UID}"
|
||||||
|
- GID: "${MISSKEY_GID}"
|
||||||
|
profiles: [web]
|
||||||
|
restart: always
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
links:
|
links:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
|
@ -26,6 +50,7 @@ services:
|
||||||
redis:
|
redis:
|
||||||
restart: always
|
restart: always
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
networks:
|
networks:
|
||||||
- internal_network
|
- internal_network
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -37,7 +62,9 @@ services:
|
||||||
|
|
||||||
db:
|
db:
|
||||||
restart: always
|
restart: always
|
||||||
image: postgres:15-alpine
|
image: l1drm/postgres-pgroonga:alpine-15-znver4
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
|
shm_size: 2gb
|
||||||
networks:
|
networks:
|
||||||
- internal_network
|
- internal_network
|
||||||
env_file:
|
env_file:
|
||||||
|
@ -49,6 +76,106 @@ services:
|
||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
replikey:
|
||||||
|
restart: always
|
||||||
|
image: l1drm/replikey:latest
|
||||||
|
profiles: [replikey-master]
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
|
links:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- internal_network
|
||||||
|
- external_network
|
||||||
|
ports:
|
||||||
|
- "5443:5443"
|
||||||
|
volumes:
|
||||||
|
- ./replikey:/etc/replikey:ro
|
||||||
|
command: [
|
||||||
|
"network",
|
||||||
|
"reverse-proxy",
|
||||||
|
"--listen",
|
||||||
|
"0.0.0.0:5443",
|
||||||
|
"--cert",
|
||||||
|
"/etc/replikey/cert.pem",
|
||||||
|
"--key",
|
||||||
|
"/etc/replikey/key.pem",
|
||||||
|
"--ca",
|
||||||
|
"/etc/replikey/ca.pem",
|
||||||
|
"--redis-sni",
|
||||||
|
"${MTLS_REDIS_SNI}",
|
||||||
|
"--redis-target",
|
||||||
|
"redis:6379",
|
||||||
|
"--postgres-sni",
|
||||||
|
"${MTLS_POSTGRES_SNI}",
|
||||||
|
"--postgres-target",
|
||||||
|
"db:5432",
|
||||||
|
]
|
||||||
|
|
||||||
|
replikey-postgres-slave:
|
||||||
|
restart: always
|
||||||
|
image: l1drm/replikey:latest
|
||||||
|
profiles: [replikey-slave]
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
|
links:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- internal_network
|
||||||
|
- external_network
|
||||||
|
ports:
|
||||||
|
# - "4001:4001"
|
||||||
|
volumes:
|
||||||
|
- ./replikey:/etc/replikey:ro
|
||||||
|
command: [
|
||||||
|
"network",
|
||||||
|
"forward-proxy",
|
||||||
|
"--listen",
|
||||||
|
"0.0.0.0:4001",
|
||||||
|
"--sni",
|
||||||
|
"${MTLS_POSTGRES_SNI}",
|
||||||
|
"--target",
|
||||||
|
"db:5432",
|
||||||
|
"--cert",
|
||||||
|
"/etc/replikey/cert.pem",
|
||||||
|
"--key",
|
||||||
|
"/etc/replikey/key.pem",
|
||||||
|
"--ca",
|
||||||
|
"/etc/replikey/ca.pem",
|
||||||
|
]
|
||||||
|
|
||||||
|
replikey-redis-slave:
|
||||||
|
restart: always
|
||||||
|
image: l1drm/replikey:latest
|
||||||
|
profiles: [replikey-slave]
|
||||||
|
user: "${MISSKEY_UID}:${MISSKEY_GID}"
|
||||||
|
links:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- internal_network
|
||||||
|
- external_network
|
||||||
|
ports:
|
||||||
|
# - "4002:4002"
|
||||||
|
volumes:
|
||||||
|
- ./replikey:/etc/replikey:ro
|
||||||
|
command: [
|
||||||
|
"network",
|
||||||
|
"forward-proxy",
|
||||||
|
"--listen",
|
||||||
|
"0.0.0.0:4002",
|
||||||
|
"--sni",
|
||||||
|
"${MTLS_REDIS_SNI}",
|
||||||
|
"--target",
|
||||||
|
"redis:6379",
|
||||||
|
"--cert",
|
||||||
|
"/etc/replikey/cert.pem",
|
||||||
|
"--key",
|
||||||
|
"/etc/replikey/key.pem",
|
||||||
|
"--ca",
|
||||||
|
"/etc/replikey/ca.pem",
|
||||||
|
]
|
||||||
|
|
||||||
# mcaptcha:
|
# mcaptcha:
|
||||||
# restart: always
|
# restart: always
|
||||||
# image: mcaptcha/mcaptcha:latest
|
# image: mcaptcha/mcaptcha:latest
|
||||||
|
|
|
@ -586,6 +586,7 @@ masterVolume: "Volum principal"
|
||||||
notUseSound: "Sense so"
|
notUseSound: "Sense so"
|
||||||
useSoundOnlyWhenActive: "Reproduir sons només quan Misskey estigui actiu"
|
useSoundOnlyWhenActive: "Reproduir sons només quan Misskey estigui actiu"
|
||||||
details: "Detalls"
|
details: "Detalls"
|
||||||
|
renoteDetails: "Més informació sobre l'impuls "
|
||||||
chooseEmoji: "Tria un emoji"
|
chooseEmoji: "Tria un emoji"
|
||||||
unableToProcess: "L'operació no pot ser completada "
|
unableToProcess: "L'operació no pot ser completada "
|
||||||
recentUsed: "Utilitzat recentment"
|
recentUsed: "Utilitzat recentment"
|
||||||
|
|
|
@ -1242,6 +1242,7 @@ keepOriginalFilenameDescription: "Wenn diese Einstellung deaktiviert ist, wird d
|
||||||
noDescription: "Keine Beschreibung vorhanden"
|
noDescription: "Keine Beschreibung vorhanden"
|
||||||
tryAgain: "Bitte später erneut versuchen"
|
tryAgain: "Bitte später erneut versuchen"
|
||||||
confirmWhenRevealingSensitiveMedia: "Das Anzeigen von sensiblen Medien bestätigen"
|
confirmWhenRevealingSensitiveMedia: "Das Anzeigen von sensiblen Medien bestätigen"
|
||||||
|
sensitiveMediaRevealConfirm: "Es könnte sich um sensible Medien handeln. Möchtest du sie anzeigen?"
|
||||||
createdLists: "Erstellte Listen"
|
createdLists: "Erstellte Listen"
|
||||||
createdAntennas: "Erstellte Antennen"
|
createdAntennas: "Erstellte Antennen"
|
||||||
fromX: "Von {x}"
|
fromX: "Von {x}"
|
||||||
|
@ -1253,6 +1254,8 @@ thereAreNChanges: "Es gibt {n} Änderung(en)"
|
||||||
signinWithPasskey: "Mit Passkey anmelden"
|
signinWithPasskey: "Mit Passkey anmelden"
|
||||||
passkeyVerificationFailed: "Die Passkey-Verifizierung ist fehlgeschlagen."
|
passkeyVerificationFailed: "Die Passkey-Verifizierung ist fehlgeschlagen."
|
||||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Die Verifizierung des Passkeys war erfolgreich, aber die passwortlose Anmeldung ist deaktiviert."
|
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Die Verifizierung des Passkeys war erfolgreich, aber die passwortlose Anmeldung ist deaktiviert."
|
||||||
|
messageToFollower: "Nachricht an die Follower"
|
||||||
|
testCaptchaWarning: "Diese Funktion ist für CAPTCHA-Testzwecke gedacht.\n<strong>Nicht in einer Produktivumgebung verwenden.</strong>"
|
||||||
prohibitedWordsForNameOfUser: "Verbotene Begriffe für Benutzernamen"
|
prohibitedWordsForNameOfUser: "Verbotene Begriffe für Benutzernamen"
|
||||||
prohibitedWordsForNameOfUserDescription: "Wenn eine Zeichenfolge aus dieser Liste im Namen eines Benutzers enthalten ist, wird der Benutzername abgelehnt. Benutzer mit Moderatorenrechten sind von dieser Einschränkung nicht betroffen."
|
prohibitedWordsForNameOfUserDescription: "Wenn eine Zeichenfolge aus dieser Liste im Namen eines Benutzers enthalten ist, wird der Benutzername abgelehnt. Benutzer mit Moderatorenrechten sind von dieser Einschränkung nicht betroffen."
|
||||||
yourNameContainsProhibitedWords: "Dein Name enthält einen verbotenen Begriff"
|
yourNameContainsProhibitedWords: "Dein Name enthält einen verbotenen Begriff"
|
||||||
|
@ -1264,6 +1267,7 @@ _accountSettings:
|
||||||
requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln."
|
requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln."
|
||||||
requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern."
|
requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern."
|
||||||
makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar"
|
makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar"
|
||||||
|
makeNotesHiddenBefore: "Frühere Notizen privat machen"
|
||||||
mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden."
|
mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden."
|
||||||
_abuseUserReport:
|
_abuseUserReport:
|
||||||
forward: "Weiterleiten"
|
forward: "Weiterleiten"
|
||||||
|
@ -1274,6 +1278,7 @@ _delivery:
|
||||||
stop: "Gesperrt"
|
stop: "Gesperrt"
|
||||||
_type:
|
_type:
|
||||||
none: "Wird veröffentlicht"
|
none: "Wird veröffentlicht"
|
||||||
|
manuallySuspended: "Manuell gesperrt"
|
||||||
_bubbleGame:
|
_bubbleGame:
|
||||||
howToPlay: "Wie man spielt"
|
howToPlay: "Wie man spielt"
|
||||||
hold: "Halten"
|
hold: "Halten"
|
||||||
|
|
|
@ -586,6 +586,7 @@ masterVolume: "Master volume"
|
||||||
notUseSound: "Disable sound"
|
notUseSound: "Disable sound"
|
||||||
useSoundOnlyWhenActive: "Output sounds only if Misskey is active."
|
useSoundOnlyWhenActive: "Output sounds only if Misskey is active."
|
||||||
details: "Details"
|
details: "Details"
|
||||||
|
renoteDetails: "Renote details"
|
||||||
chooseEmoji: "Select an emoji"
|
chooseEmoji: "Select an emoji"
|
||||||
unableToProcess: "The operation could not be completed"
|
unableToProcess: "The operation could not be completed"
|
||||||
recentUsed: "Recently used"
|
recentUsed: "Recently used"
|
||||||
|
@ -2120,6 +2121,7 @@ _permissions:
|
||||||
"read:flash-likes": "View list of liked Plays"
|
"read:flash-likes": "View list of liked Plays"
|
||||||
"write:flash-likes": "Edit list of liked Plays"
|
"write:flash-likes": "Edit list of liked Plays"
|
||||||
"read:admin:abuse-user-reports": "View user reports"
|
"read:admin:abuse-user-reports": "View user reports"
|
||||||
|
"write:admin:create-account": "Create user account"
|
||||||
"write:admin:delete-account": "Delete user account"
|
"write:admin:delete-account": "Delete user account"
|
||||||
"write:admin:delete-all-files-of-a-user": "Delete all files of a user"
|
"write:admin:delete-all-files-of-a-user": "Delete all files of a user"
|
||||||
"read:admin:index-stats": "View database index stats"
|
"read:admin:index-stats": "View database index stats"
|
||||||
|
|
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
|
@ -8250,6 +8250,10 @@ export interface Locale extends ILocale {
|
||||||
* ユーザーからの通報を見る
|
* ユーザーからの通報を見る
|
||||||
*/
|
*/
|
||||||
"read:admin:abuse-user-reports": string;
|
"read:admin:abuse-user-reports": string;
|
||||||
|
/**
|
||||||
|
* ユーザーアカウントを作成する
|
||||||
|
*/
|
||||||
|
"write:admin:create-account": string;
|
||||||
/**
|
/**
|
||||||
* ユーザーアカウントを削除する
|
* ユーザーアカウントを削除する
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -2166,6 +2166,7 @@ _permissions:
|
||||||
"read:flash-likes": "Playのいいねを見る"
|
"read:flash-likes": "Playのいいねを見る"
|
||||||
"write:flash-likes": "Playのいいねを操作する"
|
"write:flash-likes": "Playのいいねを操作する"
|
||||||
"read:admin:abuse-user-reports": "ユーザーからの通報を見る"
|
"read:admin:abuse-user-reports": "ユーザーからの通報を見る"
|
||||||
|
"write:admin:create-account": "ユーザーアカウントを作成する"
|
||||||
"write:admin:delete-account": "ユーザーアカウントを削除する"
|
"write:admin:delete-account": "ユーザーアカウントを削除する"
|
||||||
"write:admin:delete-all-files-of-a-user": "ユーザーのすべてのファイルを削除する"
|
"write:admin:delete-all-files-of-a-user": "ユーザーのすべてのファイルを削除する"
|
||||||
"read:admin:index-stats": "データベースインデックスに関する情報を見る"
|
"read:admin:index-stats": "データベースインデックスに関する情報を見る"
|
||||||
|
|
|
@ -586,6 +586,7 @@ masterVolume: "마스터 볼륨"
|
||||||
notUseSound: "음소거 하기"
|
notUseSound: "음소거 하기"
|
||||||
useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기"
|
useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기"
|
||||||
details: "자세히"
|
details: "자세히"
|
||||||
|
renoteDetails: "리노트 상세 내용"
|
||||||
chooseEmoji: "이모지 선택"
|
chooseEmoji: "이모지 선택"
|
||||||
unableToProcess: "작업을 완료할 수 없습니다"
|
unableToProcess: "작업을 완료할 수 없습니다"
|
||||||
recentUsed: "최근 사용"
|
recentUsed: "최근 사용"
|
||||||
|
@ -1299,6 +1300,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해
|
||||||
lockdown: "잠금"
|
lockdown: "잠금"
|
||||||
pleaseSelectAccount: "계정을 선택해주세요."
|
pleaseSelectAccount: "계정을 선택해주세요."
|
||||||
availableRoles: "사용 가능한 역할"
|
availableRoles: "사용 가능한 역할"
|
||||||
|
acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했습니다."
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "콘텐츠 열람을 위해 로그인으 필수로 설정하기"
|
requireSigninToViewContents: "콘텐츠 열람을 위해 로그인으 필수로 설정하기"
|
||||||
requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다."
|
requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다."
|
||||||
|
@ -1455,6 +1457,8 @@ _serverSettings:
|
||||||
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
|
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
|
||||||
inquiryUrl: "문의처 URL"
|
inquiryUrl: "문의처 URL"
|
||||||
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
|
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
|
||||||
|
openRegistration: "회원 가입을 활성화 하기"
|
||||||
|
openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다."
|
||||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "다른 계정에서 이 계정으로 이사"
|
moveFrom: "다른 계정에서 이 계정으로 이사"
|
||||||
|
@ -2737,3 +2741,6 @@ _selfXssPrevention:
|
||||||
description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
|
description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
|
||||||
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
|
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
|
||||||
description3: "자세한 내용은 여기를 확인해 주세요. {link}"
|
description3: "자세한 내용은 여기를 확인해 주세요. {link}"
|
||||||
|
_followRequest:
|
||||||
|
recieved: "받은 신청"
|
||||||
|
sent: "보낸 신청"
|
||||||
|
|
|
@ -1707,9 +1707,9 @@ _achievements:
|
||||||
description: "在元旦登入"
|
description: "在元旦登入"
|
||||||
flavor: "今年也请对本服务器多多指教!"
|
flavor: "今年也请对本服务器多多指教!"
|
||||||
_cookieClicked:
|
_cookieClicked:
|
||||||
title: "点击饼干小游戏"
|
title: "饼干点点乐"
|
||||||
description: "点击了饼干"
|
description: "点击了饼干"
|
||||||
flavor: "用错软件了?"
|
flavor: "穿越了?"
|
||||||
_brainDiver:
|
_brainDiver:
|
||||||
title: "Brain Diver"
|
title: "Brain Diver"
|
||||||
description: "发布了包含 Brain Diver 链接的帖子"
|
description: "发布了包含 Brain Diver 链接的帖子"
|
||||||
|
|
|
@ -586,6 +586,7 @@ masterVolume: "主音量"
|
||||||
notUseSound: "關閉音效"
|
notUseSound: "關閉音效"
|
||||||
useSoundOnlyWhenActive: "瀏覽器在前景運作時,Misskey 才會發出音效"
|
useSoundOnlyWhenActive: "瀏覽器在前景運作時,Misskey 才會發出音效"
|
||||||
details: "詳細資訊"
|
details: "詳細資訊"
|
||||||
|
renoteDetails: "轉發貼文的細節"
|
||||||
chooseEmoji: "選擇您的表情符號"
|
chooseEmoji: "選擇您的表情符號"
|
||||||
unableToProcess: "操作無法完成"
|
unableToProcess: "操作無法完成"
|
||||||
recentUsed: "最近使用"
|
recentUsed: "最近使用"
|
||||||
|
|
16
package.json
16
package.json
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2024.11.0-alpha.2",
|
"version": "2024.11.0-yumechinokuni.8",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/misskey-dev/misskey.git"
|
"url": "https://forge.yumechi.jp/yume.yumechi-no-kuni.git"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.6.0",
|
"packageManager": "pnpm@9.6.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
@ -24,15 +24,15 @@
|
||||||
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
||||||
"build-storybook": "pnpm --filter frontend build-storybook",
|
"build-storybook": "pnpm --filter frontend build-storybook",
|
||||||
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
|
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
|
||||||
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
|
"start": "pnpm check:connect && cd packages/backend && cross-env RUN_MODE=web node ./built/boot/entry.js",
|
||||||
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
"start:test": "cd packages/backend && cross-env NODE_ENV=test RUN_MODE=test node ./built/boot/entry.js",
|
||||||
"init": "pnpm migrate",
|
"init": "pnpm migrate",
|
||||||
"migrate": "cd packages/backend && pnpm migrate",
|
"migrate": "cd packages/backend && pnpm migrate",
|
||||||
"revert": "cd packages/backend && pnpm revert",
|
"revert": "cd packages/backend && pnpm revert",
|
||||||
"check:connect": "cd packages/backend && pnpm check:connect",
|
"check:connect": "cd packages/backend && pnpm check:connect",
|
||||||
"migrateandstart": "pnpm migrate && pnpm start",
|
"migrateandstart": "pnpm migrate && pnpm start",
|
||||||
"watch": "pnpm dev",
|
"watch": "pnpm dev",
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "cross-env RUN_MODE=dev node scripts/dev.mjs",
|
||||||
"lint": "pnpm -r lint",
|
"lint": "pnpm -r lint",
|
||||||
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
|
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||||
"cy:run": "pnpm cypress run",
|
"cy:run": "pnpm cypress run",
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssnano": "6.1.2",
|
"cssnano": "6.1.2",
|
||||||
"execa": "8.0.1",
|
"execa": "9.5.1",
|
||||||
"fast-glob": "3.3.2",
|
"fast-glob": "3.3.2",
|
||||||
"ignore-walk": "6.0.5",
|
"ignore-walk": "6.0.5",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
|
@ -61,14 +61,14 @@
|
||||||
"terser": "5.36.0",
|
"terser": "5.36.0",
|
||||||
"typescript": "5.6.3",
|
"typescript": "5.6.3",
|
||||||
"esbuild": "0.24.0",
|
"esbuild": "0.24.0",
|
||||||
"glob": "11.0.0"
|
"glob": "11.0.0",
|
||||||
|
"cross-env": "7.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "2.0.3",
|
"@misskey-dev/eslint-plugin": "2.0.3",
|
||||||
"@types/node": "22.9.0",
|
"@types/node": "22.9.0",
|
||||||
"@typescript-eslint/eslint-plugin": "7.17.0",
|
"@typescript-eslint/eslint-plugin": "7.17.0",
|
||||||
"@typescript-eslint/parser": "7.17.0",
|
"@typescript-eslint/parser": "7.17.0",
|
||||||
"cross-env": "7.0.3",
|
|
||||||
"cypress": "13.15.2",
|
"cypress": "13.15.2",
|
||||||
"eslint": "9.14.0",
|
"eslint": "9.14.0",
|
||||||
"globals": "15.12.0",
|
"globals": "15.12.0",
|
||||||
|
|
18
packages/backend/migration/1730937958242-Pgroonga.js
Normal file
18
packages/backend/migration/1730937958242-Pgroonga.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export class Pgroonga1730937958242 {
|
||||||
|
name = 'Pgroonga1730937958242'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_f27f5d88941e57442be75ba9c8" ON "note" USING "pgroonga" ("text")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_7cc8d9b0ee7861b4e5dc86ad85" ON "note" USING "pgroonga" ("cw" pgroonga_varchar_full_text_search_ops_v2)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_065d4d8f3b5adb4a08841eae3c" ON "user" USING "pgroonga" ("name" pgroonga_varchar_full_text_search_ops_v2)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_fcb770976ff8240af5799e3ffc" ON "user_profile" USING "pgroonga" ("description" pgroonga_varchar_full_text_search_ops_v2) `);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_f27f5d88941e57442be75ba9c8"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_7cc8d9b0ee7861b4e5dc86ad85"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_065d4d8f3b5adb4a08841eae3c"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_fcb770976ff8240af5799e3ffc"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class IncreaseCharacterLimits1731757142918 {
|
||||||
|
name = 'IncreaseCharacterLimits1731757142918'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_7cc8d9b0ee7861b4e5dc86ad85"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE varchar(32768)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "cw" TYPE text`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_7cc8d9b0ee7861b4e5dc86ad85" ON "note" USING "pgroonga" ("cw")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "cw" TYPE varchar(512)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE varchar(8192)`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7cc8d9b0ee7861b4e5dc86ad85"`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_7cc8d9b0ee7861b4e5dc86ad85" ON "note" USING "pgroonga" ("cw" pgroonga_varchar_full_text_search_ops)`);
|
||||||
|
}
|
||||||
|
}
|
16
packages/backend/migration/1732071810971-IndexUserDeleted.js
Normal file
16
packages/backend/migration/1732071810971-IndexUserDeleted.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project and yumechi
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class IndexUserDeleted1732071810971 {
|
||||||
|
name = 'IndexUserDeleted1732071810971'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_199b79e682bdc5ba946f491686" ON "user" ("isDeleted")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_199b79e682bdc5ba946f491686"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -134,8 +134,8 @@
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"jsonld": "8.3.2",
|
"jsonld": "8.3.2",
|
||||||
"jsrsasign": "11.1.0",
|
"jsrsasign": "11.1.0",
|
||||||
"meilisearch": "0.45.0",
|
|
||||||
"juice": "11.0.0",
|
"juice": "11.0.0",
|
||||||
|
"meilisearch": "0.45.0",
|
||||||
"mfm-js": "0.24.0",
|
"mfm-js": "0.24.0",
|
||||||
"microformats-parser": "2.0.2",
|
"microformats-parser": "2.0.2",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
|
@ -156,6 +156,7 @@
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"pkce-challenge": "4.1.0",
|
"pkce-challenge": "4.1.0",
|
||||||
"probe-image-size": "7.2.3",
|
"probe-image-size": "7.2.3",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"promise-limit": "2.7.0",
|
"promise-limit": "2.7.0",
|
||||||
"pug": "3.0.3",
|
"pug": "3.0.3",
|
||||||
"punycode": "2.3.1",
|
"punycode": "2.3.1",
|
||||||
|
@ -234,7 +235,7 @@
|
||||||
"aws-sdk-client-mock": "4.0.1",
|
"aws-sdk-client-mock": "4.0.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint-plugin-import": "2.30.0",
|
"eslint-plugin-import": "2.30.0",
|
||||||
"execa": "8.0.1",
|
"execa": "9.5.1",
|
||||||
"fkill": "9.0.0",
|
"fkill": "9.0.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-mock": "29.7.0",
|
"jest-mock": "29.7.0",
|
||||||
|
|
|
@ -22,6 +22,7 @@ async function connectToRedis(redisOptions) {
|
||||||
lazyConnect: true,
|
lazyConnect: true,
|
||||||
reconnectOnError: false,
|
reconnectOnError: false,
|
||||||
showFriendlyErrorStack: true,
|
showFriendlyErrorStack: true,
|
||||||
|
connectTimeout: 10000,
|
||||||
});
|
});
|
||||||
redis.on('error', e => reject(e));
|
redis.on('error', e => reject(e));
|
||||||
|
|
||||||
|
@ -50,7 +51,9 @@ const promises = Array
|
||||||
]))
|
]))
|
||||||
.map(connectToRedis)
|
.map(connectToRedis)
|
||||||
.concat([
|
.concat([
|
||||||
connectToPostgres()
|
connectToPostgres().then(() => { console.log('Connected to PostgreSQL.'); }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
|
|
@ -24,7 +24,7 @@ const $config: Provider = {
|
||||||
const $db: Provider = {
|
const $db: Provider = {
|
||||||
provide: DI.db,
|
provide: DI.db,
|
||||||
useFactory: async (config) => {
|
useFactory: async (config) => {
|
||||||
const db = createPostgresDataSource(config);
|
const db = createPostgresDataSource(config, true);
|
||||||
return await db.initialize();
|
return await db.initialize();
|
||||||
},
|
},
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
|
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
|
||||||
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
|
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
|
||||||
import { NestLogger } from '@/NestLogger.js';
|
import { NestLogger } from '@/NestLogger.js';
|
||||||
|
@ -12,8 +13,9 @@ import { QueueStatsService } from '@/daemons/QueueStatsService.js';
|
||||||
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
|
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
|
||||||
import { ServerService } from '@/server/ServerService.js';
|
import { ServerService } from '@/server/ServerService.js';
|
||||||
import { MainModule } from '@/MainModule.js';
|
import { MainModule } from '@/MainModule.js';
|
||||||
|
import { MetricsService } from '@/server/api/MetricsService.js';
|
||||||
|
|
||||||
export async function server() {
|
export async function server(workerRegistry?: prom.AggregatorRegistry<prom.PrometheusContentType>) {
|
||||||
const app = await NestFactory.createApplicationContext(MainModule, {
|
const app = await NestFactory.createApplicationContext(MainModule, {
|
||||||
logger: new NestLogger(),
|
logger: new NestLogger(),
|
||||||
});
|
});
|
||||||
|
@ -22,6 +24,9 @@ export async function server() {
|
||||||
await serverService.launch();
|
await serverService.launch();
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
if (workerRegistry) {
|
||||||
|
app.get(MetricsService).setWorkerRegistry(workerRegistry);
|
||||||
|
}
|
||||||
app.get(ChartManagementService).start();
|
app.get(ChartManagementService).start();
|
||||||
app.get(QueueStatsService).start();
|
app.get(QueueStatsService).start();
|
||||||
app.get(ServerStatsService).start();
|
app.get(ServerStatsService).start();
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import cluster from 'node:cluster';
|
import cluster from 'node:cluster';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import Xev from 'xev';
|
import Xev from 'xev';
|
||||||
|
@ -17,6 +18,15 @@ import { masterMain } from './master.js';
|
||||||
import { workerMain } from './worker.js';
|
import { workerMain } from './worker.js';
|
||||||
import { readyRef } from './ready.js';
|
import { readyRef } from './ready.js';
|
||||||
|
|
||||||
|
const workerRegistry = new prom.AggregatorRegistry<prom.PrometheusContentType>();
|
||||||
|
|
||||||
|
prom.collectDefaultMetrics({
|
||||||
|
labels: {
|
||||||
|
cluster_type: `${cluster.isPrimary ? 'master' : 'worker'}`,
|
||||||
|
worker_id: cluster.worker?.id.toString() || 'none'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
|
||||||
process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
|
process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
|
||||||
|
@ -69,7 +79,7 @@ process.on('exit', code => {
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
if (cluster.isPrimary || envOption.disableClustering) {
|
if (cluster.isPrimary || envOption.disableClustering) {
|
||||||
await masterMain();
|
await masterMain(workerRegistry);
|
||||||
|
|
||||||
if (cluster.isPrimary) {
|
if (cluster.isPrimary) {
|
||||||
ev.mount();
|
ev.mount();
|
||||||
|
|
|
@ -7,6 +7,7 @@ import * as fs from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import cluster from 'node:cluster';
|
import cluster from 'node:cluster';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import chalkTemplate from 'chalk-template';
|
import chalkTemplate from 'chalk-template';
|
||||||
|
@ -18,6 +19,7 @@ import type { Config } from '@/config.js';
|
||||||
import { showMachineInfo } from '@/misc/show-machine-info.js';
|
import { showMachineInfo } from '@/misc/show-machine-info.js';
|
||||||
import { envOption } from '@/env.js';
|
import { envOption } from '@/env.js';
|
||||||
import { jobQueue, server } from './common.js';
|
import { jobQueue, server } from './common.js';
|
||||||
|
import { metricGauge } from '@/server/api/MetricsService.js';
|
||||||
|
|
||||||
const _filename = fileURLToPath(import.meta.url);
|
const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
|
@ -29,6 +31,24 @@ const bootLogger = logger.createSubLogger('boot', 'magenta');
|
||||||
|
|
||||||
const themeColor = chalk.hex('#86b300');
|
const themeColor = chalk.hex('#86b300');
|
||||||
|
|
||||||
|
const mBuildInfo = metricGauge({
|
||||||
|
name: 'misskey_build_info',
|
||||||
|
help: 'Misskey build information',
|
||||||
|
labelNames: ['gitCommit', 'gitDescribe', 'node_version']
|
||||||
|
});
|
||||||
|
|
||||||
|
mBuildInfo?.set({
|
||||||
|
gitCommit: meta.gitCommit || 'unknown',
|
||||||
|
gitDescribe: meta.gitDescribe || 'unknown',
|
||||||
|
node_version: process.version
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
const mStartupTime = metricGauge({
|
||||||
|
name: 'misskey_startup_time',
|
||||||
|
help: 'Misskey startup time',
|
||||||
|
labelNames: ['pid']
|
||||||
|
});
|
||||||
|
|
||||||
function greet() {
|
function greet() {
|
||||||
if (!envOption.quiet) {
|
if (!envOption.quiet) {
|
||||||
//#region Misskey logo
|
//#region Misskey logo
|
||||||
|
@ -54,7 +74,7 @@ function greet() {
|
||||||
/**
|
/**
|
||||||
* Init master process
|
* Init master process
|
||||||
*/
|
*/
|
||||||
export async function masterMain() {
|
export async function masterMain(workerRegistry?: prom.AggregatorRegistry<prom.PrometheusContentType>) {
|
||||||
let config!: Config;
|
let config!: Config;
|
||||||
|
|
||||||
// initialize app
|
// initialize app
|
||||||
|
@ -64,6 +84,7 @@ export async function masterMain() {
|
||||||
await showMachineInfo(bootLogger);
|
await showMachineInfo(bootLogger);
|
||||||
showNodejsVersion();
|
showNodejsVersion();
|
||||||
config = loadConfigBoot();
|
config = loadConfigBoot();
|
||||||
|
|
||||||
//await connectDb();
|
//await connectDb();
|
||||||
if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString());
|
if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -91,13 +112,15 @@ export async function masterMain() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mStartupTime?.set({ pid: process.pid }, Date.now());
|
||||||
|
|
||||||
if (envOption.disableClustering) {
|
if (envOption.disableClustering) {
|
||||||
if (envOption.onlyServer) {
|
if (envOption.onlyServer) {
|
||||||
await server();
|
await server(workerRegistry);
|
||||||
} else if (envOption.onlyQueue) {
|
} else if (envOption.onlyQueue) {
|
||||||
await jobQueue();
|
await jobQueue();
|
||||||
} else {
|
} else {
|
||||||
await server();
|
await server(workerRegistry);
|
||||||
await jobQueue();
|
await jobQueue();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -106,7 +129,7 @@ export async function masterMain() {
|
||||||
} else if (envOption.onlyQueue) {
|
} else if (envOption.onlyQueue) {
|
||||||
// nop
|
// nop
|
||||||
} else {
|
} else {
|
||||||
await server();
|
await server(workerRegistry);
|
||||||
}
|
}
|
||||||
|
|
||||||
await spawnWorkers(config.clusterLimit);
|
await spawnWorkers(config.clusterLimit);
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import cluster from 'node:cluster';
|
import cluster from 'node:cluster';
|
||||||
|
import { collectDefaultMetrics, AggregatorRegistry, RegistryContentType } from 'prom-client';
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||||
import { envOption } from '@/env.js';
|
import { envOption } from '@/env.js';
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { dirname, resolve } from 'node:path';
|
||||||
import * as yaml from 'js-yaml';
|
import * as yaml from 'js-yaml';
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import type { RedisOptions } from 'ioredis';
|
import type { RedisOptions } from 'ioredis';
|
||||||
|
import { type CSPHashed, hashResource, hashSourceFile } from './server/csp.js';
|
||||||
|
|
||||||
type RedisOptionsSource = Partial<RedisOptions> & {
|
type RedisOptionsSource = Partial<RedisOptions> & {
|
||||||
host: string;
|
host: string;
|
||||||
|
@ -19,6 +20,18 @@ type RedisOptionsSource = Partial<RedisOptions> & {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrowserSandboxing = {
|
||||||
|
// send Referrer-Policy: strict-origin
|
||||||
|
strictOriginReferrer?: boolean;
|
||||||
|
csp?: {
|
||||||
|
disable?: boolean;
|
||||||
|
|
||||||
|
appendDirectives?: {
|
||||||
|
[directive: string]: string | string[];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 設定ファイルの型
|
* 設定ファイルの型
|
||||||
*/
|
*/
|
||||||
|
@ -28,6 +41,7 @@ type Source = {
|
||||||
socket?: string;
|
socket?: string;
|
||||||
chmodSocket?: string;
|
chmodSocket?: string;
|
||||||
disableHsts?: boolean;
|
disableHsts?: boolean;
|
||||||
|
hstsPreload?: boolean;
|
||||||
db: {
|
db: {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
@ -58,11 +72,16 @@ type Source = {
|
||||||
index: string;
|
index: string;
|
||||||
scope?: 'local' | 'global' | string[];
|
scope?: 'local' | 'global' | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
prometheusMetrics?: { enable: boolean, scrapeToken?: string };
|
||||||
|
|
||||||
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
|
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
|
||||||
sentryForFrontend?: { options: Partial<Sentry.NodeOptions> };
|
sentryForFrontend?: { options: Partial<Sentry.NodeOptions> };
|
||||||
|
|
||||||
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
|
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
|
||||||
|
|
||||||
|
browserSandboxing?: BrowserSandboxing;
|
||||||
|
|
||||||
setupPassword?: string;
|
setupPassword?: string;
|
||||||
|
|
||||||
proxy?: string;
|
proxy?: string;
|
||||||
|
@ -107,6 +126,7 @@ export type Config = {
|
||||||
socket: string | undefined;
|
socket: string | undefined;
|
||||||
chmodSocket: string | undefined;
|
chmodSocket: string | undefined;
|
||||||
disableHsts: boolean | undefined;
|
disableHsts: boolean | undefined;
|
||||||
|
hstsPreload: boolean | undefined;
|
||||||
db: {
|
db: {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
@ -152,7 +172,13 @@ export type Config = {
|
||||||
proxyRemoteFiles: boolean | undefined;
|
proxyRemoteFiles: boolean | undefined;
|
||||||
signToActivityPubGet: boolean | undefined;
|
signToActivityPubGet: boolean | undefined;
|
||||||
|
|
||||||
|
browserSandboxing: BrowserSandboxing;
|
||||||
|
|
||||||
|
cspPrerenderedContent: Map<string, CSPHashed>;
|
||||||
|
|
||||||
version: string;
|
version: string;
|
||||||
|
gitDescribe: string;
|
||||||
|
gitCommit: string;
|
||||||
publishTarballInsteadOfProvideRepositoryUrl: boolean;
|
publishTarballInsteadOfProvideRepositoryUrl: boolean;
|
||||||
setupPassword: string | undefined;
|
setupPassword: string | undefined;
|
||||||
host: string;
|
host: string;
|
||||||
|
@ -176,8 +202,12 @@ export type Config = {
|
||||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||||
redisForTimelines: RedisOptions & RedisOptionsSource;
|
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||||
redisForReactions: RedisOptions & RedisOptionsSource;
|
redisForReactions: RedisOptions & RedisOptionsSource;
|
||||||
|
|
||||||
|
prometheusMetrics : { enable: boolean, scrapeToken?: string } | undefined;
|
||||||
|
|
||||||
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
|
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
|
||||||
sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined;
|
sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined;
|
||||||
|
|
||||||
perChannelMaxNoteCacheCount: number;
|
perChannelMaxNoteCacheCount: number;
|
||||||
perUserNotificationsMaxCount: number;
|
perUserNotificationsMaxCount: number;
|
||||||
deactivateAntennaThreshold: number;
|
deactivateAntennaThreshold: number;
|
||||||
|
@ -216,7 +246,7 @@ export function loadConfig(): Config {
|
||||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||||
|
|
||||||
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
|
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
|
||||||
const version = meta.version;
|
const { version, gitDescribe, gitCommit } = meta;
|
||||||
const host = url.host;
|
const host = url.host;
|
||||||
const hostname = url.hostname;
|
const hostname = url.hostname;
|
||||||
const scheme = url.protocol.replace(/:$/, '');
|
const scheme = url.protocol.replace(/:$/, '');
|
||||||
|
@ -231,9 +261,21 @@ export function loadConfig(): Config {
|
||||||
: null;
|
: null;
|
||||||
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
||||||
const redis = convertRedisOptions(config.redis, host);
|
const redis = convertRedisOptions(config.redis, host);
|
||||||
|
const htmlScriptPrelude = `var VERSION = ${JSON.stringify(version)}; var CLIENT_ENTRY = ${JSON.stringify(frontendManifest['src/_boot_.ts'].file)};`;
|
||||||
|
const htmlEmbedScriptPrelude = `var VERSION = ${JSON.stringify(version)}; var CLIENT_ENTRY = ${JSON.stringify(frontendEmbedManifest['src/boot.ts'].file)};`;
|
||||||
|
const cspPrerenderedContent = new Map([
|
||||||
|
['.prelude.js', hashResource(htmlScriptPrelude)],
|
||||||
|
['.prelude.embed.js', hashResource(htmlEmbedScriptPrelude)],
|
||||||
|
...['boot.js', 'style.css', 'style.embed.css', 'boot.embed.js',
|
||||||
|
'bios.css', 'bios.js', 'cli.css', 'cli.js', 'error.css'
|
||||||
|
].map((file) => [file, hashSourceFile(`${_dirname}/server/web/${file}`)] as [string, CSPHashed]),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
|
gitCommit,
|
||||||
|
gitDescribe,
|
||||||
|
browserSandboxing: config.browserSandboxing ?? { strictOriginReferrer: true },
|
||||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||||
setupPassword: config.setupPassword,
|
setupPassword: config.setupPassword,
|
||||||
url: url.origin,
|
url: url.origin,
|
||||||
|
@ -241,6 +283,8 @@ export function loadConfig(): Config {
|
||||||
socket: config.socket,
|
socket: config.socket,
|
||||||
chmodSocket: config.chmodSocket,
|
chmodSocket: config.chmodSocket,
|
||||||
disableHsts: config.disableHsts,
|
disableHsts: config.disableHsts,
|
||||||
|
hstsPreload: config.hstsPreload ?? false,
|
||||||
|
cspPrerenderedContent,
|
||||||
host,
|
host,
|
||||||
hostname,
|
hostname,
|
||||||
scheme,
|
scheme,
|
||||||
|
@ -258,6 +302,7 @@ export function loadConfig(): Config {
|
||||||
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
|
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
|
||||||
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
|
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
|
||||||
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
|
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
|
||||||
|
prometheusMetrics: config.prometheusMetrics,
|
||||||
sentryForBackend: config.sentryForBackend,
|
sentryForBackend: config.sentryForBackend,
|
||||||
sentryForFrontend: config.sentryForFrontend,
|
sentryForFrontend: config.sentryForFrontend,
|
||||||
id: config.id,
|
id: config.id,
|
||||||
|
|
|
@ -36,7 +36,7 @@ import { GlobalEventService } from './GlobalEventService.js';
|
||||||
import { HashtagService } from './HashtagService.js';
|
import { HashtagService } from './HashtagService.js';
|
||||||
import { HttpRequestService } from './HttpRequestService.js';
|
import { HttpRequestService } from './HttpRequestService.js';
|
||||||
import { IdService } from './IdService.js';
|
import { IdService } from './IdService.js';
|
||||||
import { ImageProcessingService } from './ImageProcessingService.js';
|
import { __YUME_PRIVATE_ImageProcessingService } from './ImageProcessingService.js';
|
||||||
import { InstanceActorService } from './InstanceActorService.js';
|
import { InstanceActorService } from './InstanceActorService.js';
|
||||||
import { InternalStorageService } from './InternalStorageService.js';
|
import { InternalStorageService } from './InternalStorageService.js';
|
||||||
import { MetaService } from './MetaService.js';
|
import { MetaService } from './MetaService.js';
|
||||||
|
@ -67,7 +67,7 @@ import { UserMutingService } from './UserMutingService.js';
|
||||||
import { UserRenoteMutingService } from './UserRenoteMutingService.js';
|
import { UserRenoteMutingService } from './UserRenoteMutingService.js';
|
||||||
import { UserSuspendService } from './UserSuspendService.js';
|
import { UserSuspendService } from './UserSuspendService.js';
|
||||||
import { UserAuthService } from './UserAuthService.js';
|
import { UserAuthService } from './UserAuthService.js';
|
||||||
import { VideoProcessingService } from './VideoProcessingService.js';
|
import { __YUME_PRIVATE_VideoProcessingService } from './VideoProcessingService.js';
|
||||||
import { UserWebhookService } from './UserWebhookService.js';
|
import { UserWebhookService } from './UserWebhookService.js';
|
||||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||||
import { UtilityService } from './UtilityService.js';
|
import { UtilityService } from './UtilityService.js';
|
||||||
|
@ -179,7 +179,7 @@ const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisti
|
||||||
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
|
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
|
||||||
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
|
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
|
||||||
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
|
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
|
||||||
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
|
const $ImageProcessingService: Provider = { provide: '__YUME_PRIVATE_ImageProcessingService', useExisting: __YUME_PRIVATE_ImageProcessingService };
|
||||||
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
|
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
|
||||||
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
||||||
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
||||||
|
@ -212,7 +212,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
|
||||||
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
|
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
|
||||||
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
||||||
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
|
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
|
||||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
const $VideoProcessingService: Provider = { provide: '__YUME_PRIVATE_VideoProcessingService', useExisting: __YUME_PRIVATE_VideoProcessingService };
|
||||||
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
||||||
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
||||||
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
|
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
|
||||||
|
@ -330,7 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
HashtagService,
|
HashtagService,
|
||||||
HttpRequestService,
|
HttpRequestService,
|
||||||
IdService,
|
IdService,
|
||||||
ImageProcessingService,
|
__YUME_PRIVATE_ImageProcessingService,
|
||||||
InstanceActorService,
|
InstanceActorService,
|
||||||
InternalStorageService,
|
InternalStorageService,
|
||||||
MetaService,
|
MetaService,
|
||||||
|
@ -363,7 +363,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
UserSearchService,
|
UserSearchService,
|
||||||
UserSuspendService,
|
UserSuspendService,
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
VideoProcessingService,
|
__YUME_PRIVATE_VideoProcessingService,
|
||||||
UserWebhookService,
|
UserWebhookService,
|
||||||
SystemWebhookService,
|
SystemWebhookService,
|
||||||
WebhookTestService,
|
WebhookTestService,
|
||||||
|
@ -625,7 +625,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
HashtagService,
|
HashtagService,
|
||||||
HttpRequestService,
|
HttpRequestService,
|
||||||
IdService,
|
IdService,
|
||||||
ImageProcessingService,
|
__YUME_PRIVATE_ImageProcessingService,
|
||||||
InstanceActorService,
|
InstanceActorService,
|
||||||
InternalStorageService,
|
InternalStorageService,
|
||||||
MetaService,
|
MetaService,
|
||||||
|
@ -658,7 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
UserSearchService,
|
UserSearchService,
|
||||||
UserSuspendService,
|
UserSuspendService,
|
||||||
UserAuthService,
|
UserAuthService,
|
||||||
VideoProcessingService,
|
__YUME_PRIVATE_VideoProcessingService,
|
||||||
UserWebhookService,
|
UserWebhookService,
|
||||||
SystemWebhookService,
|
SystemWebhookService,
|
||||||
WebhookTestService,
|
WebhookTestService,
|
||||||
|
|
|
@ -47,6 +47,10 @@ export class DeleteAccountService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await this.usersRepository.update({ id: user.id, isDeleted: false }, { isDeleted: true })).affected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 物理削除する前にDelete activityを送信する
|
// 物理削除する前にDelete activityを送信する
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user)) {
|
||||||
// 知り得る全SharedInboxにDelete配信
|
// 知り得る全SharedInboxにDelete配信
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as stream from 'node:stream/promises';
|
import * as stream from 'node:stream/promises';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import ipaddr from 'ipaddr.js';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import got, * as Got from 'got';
|
import got, * as Got from 'got';
|
||||||
import { parse } from 'content-disposition';
|
import { parse } from 'content-disposition';
|
||||||
|
@ -45,6 +44,14 @@ export class DownloadService {
|
||||||
const maxSize = this.config.maxFileSize;
|
const maxSize = this.config.maxFileSize;
|
||||||
|
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
|
if (urlObj.protocol && urlObj.protocol !== 'https:') {
|
||||||
|
throw new Error(`Unsupported protocol: ${urlObj.protocol}, only HTTPS is supported`);
|
||||||
|
}
|
||||||
|
urlObj.protocol = 'https:';
|
||||||
|
if (urlObj.port && urlObj.port !== '443') {
|
||||||
|
throw new Error(`Unsupported port: ${urlObj.port}, only 443 is supported`);
|
||||||
|
}
|
||||||
|
|
||||||
let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
|
let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
|
||||||
|
|
||||||
const req = got.stream(url, {
|
const req = got.stream(url, {
|
||||||
|
@ -61,7 +68,6 @@ export class DownloadService {
|
||||||
request: operationTimeout, // whole operation timeout
|
request: operationTimeout, // whole operation timeout
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
http: this.httpRequestService.httpAgent,
|
|
||||||
https: this.httpRequestService.httpsAgent,
|
https: this.httpRequestService.httpsAgent,
|
||||||
},
|
},
|
||||||
http2: false, // default
|
http2: false, // default
|
||||||
|
@ -70,13 +76,6 @@ export class DownloadService {
|
||||||
},
|
},
|
||||||
enableUnixSockets: false,
|
enableUnixSockets: false,
|
||||||
}).on('response', (res: Got.Response) => {
|
}).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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = res.headers['content-length'];
|
const contentLength = res.headers['content-length'];
|
||||||
if (contentLength != null) {
|
if (contentLength != null) {
|
||||||
const size = Number(contentLength);
|
const size = Number(contentLength);
|
||||||
|
@ -139,18 +138,4 @@ export class DownloadService {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private isPrivateIp(ip: string): boolean {
|
|
||||||
const parsedIp = ipaddr.parse(ip);
|
|
||||||
|
|
||||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
|
||||||
const cidr = ipaddr.parseCIDR(net);
|
|
||||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedIp.range() !== 'unicast';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,8 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
|
import { __YUME_PRIVATE_VideoProcessingService } from '@/core/VideoProcessingService.js';
|
||||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import type { MiDriveFolder } from '@/models/DriveFolder.js';
|
import type { MiDriveFolder } from '@/models/DriveFolder.js';
|
||||||
|
@ -87,9 +87,9 @@ type UploadFromUrlArgs = {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriveService {
|
export class DriveService {
|
||||||
public static NoSuchFolderError = class extends Error {};
|
public static NoSuchFolderError = class extends Error { };
|
||||||
public static InvalidFileNameError = class extends Error {};
|
public static InvalidFileNameError = class extends Error { };
|
||||||
public static CannotUnmarkSensitiveError = class extends Error {};
|
public static CannotUnmarkSensitiveError = class extends Error { };
|
||||||
private registerLogger: Logger;
|
private registerLogger: Logger;
|
||||||
private downloaderLogger: Logger;
|
private downloaderLogger: Logger;
|
||||||
private deleteLogger: Logger;
|
private deleteLogger: Logger;
|
||||||
|
@ -120,8 +120,8 @@ export class DriveService {
|
||||||
private downloadService: DownloadService,
|
private downloadService: DownloadService,
|
||||||
private internalStorageService: InternalStorageService,
|
private internalStorageService: InternalStorageService,
|
||||||
private s3Service: S3Service,
|
private s3Service: S3Service,
|
||||||
private imageProcessingService: ImageProcessingService,
|
private privateImageProcessingService: __YUME_PRIVATE_ImageProcessingService,
|
||||||
private videoProcessingService: VideoProcessingService,
|
private privateVideoProcessingService: __YUME_PRIVATE_VideoProcessingService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
|
@ -147,11 +147,11 @@ export class DriveService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
private async save(file: MiDriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<MiDriveFile> {
|
private async save(file: MiDriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<MiDriveFile> {
|
||||||
// thunbnail, webpublic を必要なら生成
|
// thunbnail, webpublic を必要なら生成
|
||||||
const alts = await this.generateAlts(path, type, !file.uri);
|
const alts = await this.generateAlts(path, type, !file.uri);
|
||||||
|
|
||||||
if (this.meta.useObjectStorage) {
|
if (this.meta.useObjectStorage) {
|
||||||
//#region ObjectStorage params
|
//#region ObjectStorage params
|
||||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||||
|
|
||||||
if (ext === '') {
|
if (ext === '') {
|
||||||
|
@ -170,11 +170,11 @@ export class DriveService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = this.meta.objectStorageBaseUrl
|
const baseUrl = this.meta.objectStorageBaseUrl
|
||||||
?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`;
|
?? `${this.meta.objectStorageUseSSL ? 'https' : 'http'}://${this.meta.objectStorageEndpoint}${this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : ''}/${this.meta.objectStorageBucket}`;
|
||||||
|
|
||||||
// for original
|
// for original
|
||||||
const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`;
|
const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`;
|
||||||
const url = `${ baseUrl }/${ key }`;
|
const url = `${baseUrl}/${key}`;
|
||||||
|
|
||||||
// for alts
|
// for alts
|
||||||
let webpublicKey: string | null = null;
|
let webpublicKey: string | null = null;
|
||||||
|
@ -191,7 +191,7 @@ export class DriveService {
|
||||||
|
|
||||||
if (alts.webpublic) {
|
if (alts.webpublic) {
|
||||||
webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
webpublicUrl = `${baseUrl}/${webpublicKey}`;
|
||||||
|
|
||||||
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
||||||
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
|
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
|
||||||
|
@ -199,7 +199,7 @@ export class DriveService {
|
||||||
|
|
||||||
if (alts.thumbnail) {
|
if (alts.thumbnail) {
|
||||||
thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
thumbnailUrl = `${baseUrl}/${thumbnailKey}`;
|
||||||
|
|
||||||
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
||||||
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
|
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
|
||||||
|
@ -277,7 +277,7 @@ export class DriveService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
|
const thumbnail = await this.privateVideoProcessingService.generateVideoThumbnail(path);
|
||||||
return {
|
return {
|
||||||
webpublic: null,
|
webpublic: null,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
|
@ -311,9 +311,9 @@ export class DriveService {
|
||||||
satisfyWebpublic = !!(
|
satisfyWebpublic = !!(
|
||||||
type !== 'image/svg+xml' && // security reason
|
type !== 'image/svg+xml' && // security reason
|
||||||
type !== 'image/avif' && // not supported by Mastodon and MS Edge
|
type !== 'image/avif' && // not supported by Mastodon and MS Edge
|
||||||
!(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) &&
|
!(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) &&
|
||||||
metadata.width && metadata.width <= 2048 &&
|
metadata.width && metadata.width <= 2048 &&
|
||||||
metadata.height && metadata.height <= 2048
|
metadata.height && metadata.height <= 2048
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn(`sharp failed: ${err}`);
|
this.registerLogger.warn(`sharp failed: ${err}`);
|
||||||
|
@ -331,9 +331,9 @@ export class DriveService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) {
|
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) {
|
||||||
webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048);
|
webpublic = await this.privateImageProcessingService.convertSharpToWebp(img, 2048, 2048);
|
||||||
} else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) {
|
} else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) {
|
||||||
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
|
webpublic = await this.privateImageProcessingService.convertSharpToPng(img, 2048, 2048);
|
||||||
} else {
|
} else {
|
||||||
this.registerLogger.debug('web image not created (not an required image)');
|
this.registerLogger.debug('web image not created (not an required image)');
|
||||||
}
|
}
|
||||||
|
@ -352,9 +352,9 @@ export class DriveService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAnimated) {
|
if (isAnimated) {
|
||||||
thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 });
|
thumbnail = await this.privateImageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 });
|
||||||
} else {
|
} else {
|
||||||
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
|
thumbnail = await this.privateImageProcessingService.convertSharpToWebp(img, 498, 422);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
|
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
|
||||||
|
@ -470,11 +470,11 @@ export class DriveService {
|
||||||
const info = await this.fileInfoService.getFileInfo(path, {
|
const info = await this.fileInfoService.getFileInfo(path, {
|
||||||
skipSensitiveDetection: skipNsfwCheck,
|
skipSensitiveDetection: skipNsfwCheck,
|
||||||
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
|
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
|
||||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||||
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||||
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||||
0.5,
|
0.5,
|
||||||
sensitiveThresholdForPorn: 0.75,
|
sensitiveThresholdForPorn: 0.75,
|
||||||
enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos,
|
enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos,
|
||||||
});
|
});
|
||||||
|
@ -494,7 +494,7 @@ export class DriveService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user && !force) {
|
if (user && !force) {
|
||||||
// Check if there is a file with the same hash
|
// Check if there is a file with the same hash
|
||||||
const matched = await this.driveFilesRepository.findOneBy({
|
const matched = await this.driveFilesRepository.findOneBy({
|
||||||
md5: info.md5,
|
md5: info.md5,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -582,7 +582,7 @@ export class DriveService {
|
||||||
file.maybePorn = info.porn;
|
file.maybePorn = info.porn;
|
||||||
file.isSensitive = user
|
file.isSensitive = user
|
||||||
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
|
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
|
||||||
sensitive ?? false
|
sensitive ?? false
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
if (user && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) file.isSensitive = true;
|
if (user && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) file.isSensitive = true;
|
||||||
|
@ -616,7 +616,7 @@ export class DriveService {
|
||||||
|
|
||||||
file = await this.driveFilesRepository.insertOne(file);
|
file = await this.driveFilesRepository.insertOne(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// duplicate key error (when already registered)
|
// duplicate key error (when already registered)
|
||||||
if (isDuplicateKeyValueError(err)) {
|
if (isDuplicateKeyValueError(err)) {
|
||||||
this.registerLogger.info(`already registered ${file.uri}`);
|
this.registerLogger.info(`already registered ${file.uri}`);
|
||||||
|
|
||||||
|
|
|
@ -145,6 +145,7 @@ export class EmailService {
|
||||||
try {
|
try {
|
||||||
// TODO: htmlサニタイズ
|
// TODO: htmlサニタイズ
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
|
replyTo: this.meta.maintainerEmail ? { name: this.meta.maintainerName || 'Instance Maintainer', address: this.meta.maintainerEmail } : undefined,
|
||||||
from: this.meta.email!,
|
from: this.meta.email!,
|
||||||
to: to,
|
to: to,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
|
@ -312,6 +313,7 @@ export class EmailService {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: truemailAuthKey,
|
Authorization: truemailAuthKey,
|
||||||
},
|
},
|
||||||
|
isLocalAddressAllowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const json = (await res.json()) as {
|
const json = (await res.json()) as {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||||
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||||
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
||||||
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60 * 48; // 48時間ごと
|
||||||
|
|
||||||
const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime();
|
const featuredEpoc = new Date('2023-01-01T00:00:00Z').getTime();
|
||||||
|
|
||||||
|
|
|
@ -6,41 +6,122 @@
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
|
import ipaddr from 'ipaddr.js';
|
||||||
import CacheableLookup from 'cacheable-lookup';
|
import CacheableLookup from 'cacheable-lookup';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
import { HttpsProxyAgent } from 'hpagent';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { StatusError } from '@/misc/status-error.js';
|
import { StatusError } from '@/misc/status-error.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
|
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
import type { IObject } from '@/core/activitypub/type.js';
|
import type { IObject } from '@/core/activitypub/type.js';
|
||||||
import type { Response } from 'node-fetch';
|
import type { Response } from 'node-fetch';
|
||||||
import type { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
|
|
||||||
export type HttpRequestSendOptions = {
|
export type HttpRequestSendOptions = {
|
||||||
throwErrorWhenResponseNotOk: boolean;
|
throwErrorWhenResponseNotOk: boolean;
|
||||||
validators?: ((res: Response) => void)[];
|
validators?: ((res: Response) => void)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare module 'node:http' {
|
||||||
|
interface Agent {
|
||||||
|
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpRequestServiceAgent extends http.Agent {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
options?: http.AgentOptions,
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||||
|
const socket = super.createConnection(options, callback)
|
||||||
|
.on('connect', () => {
|
||||||
|
const address = socket.remoteAddress;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (address && ipaddr.isValid(address)) {
|
||||||
|
if (this.isPrivateIp(address)) {
|
||||||
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
|
const cidr = ipaddr.parseCIDR(net);
|
||||||
|
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp.range() !== 'unicast';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpsRequestServiceAgent extends https.Agent {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
options?: https.AgentOptions,
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||||
|
const socket = super.createConnection(options, callback)
|
||||||
|
.on('connect', () => {
|
||||||
|
const address = socket.remoteAddress;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (address && ipaddr.isValid(address)) {
|
||||||
|
if (this.isPrivateIp(address)) {
|
||||||
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
|
const cidr = ipaddr.parseCIDR(net);
|
||||||
|
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp.range() !== 'unicast';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpRequestService {
|
export class HttpRequestService {
|
||||||
/**
|
/**
|
||||||
* Get http non-proxy agent
|
* Get https non-proxy agent (without local address filtering)
|
||||||
*/
|
*/
|
||||||
private http: http.Agent;
|
private httpsNative: https.Agent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get https non-proxy agent
|
* Get https non-proxy agent
|
||||||
*/
|
*/
|
||||||
private https: https.Agent;
|
private https: https.Agent;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get http proxy or non-proxy agent
|
|
||||||
*/
|
|
||||||
public httpAgent: http.Agent;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get https proxy or non-proxy agent
|
* Get https proxy or non-proxy agent
|
||||||
*/
|
*/
|
||||||
|
@ -56,34 +137,20 @@ export class HttpRequestService {
|
||||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||||
});
|
});
|
||||||
|
|
||||||
this.http = new http.Agent({
|
const agentOption = {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
keepAliveMsecs: 30 * 1000,
|
keepAliveMsecs: 30 * 1000,
|
||||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||||
localAddress: config.outgoingAddress,
|
localAddress: config.outgoingAddress,
|
||||||
});
|
minVersion: 'TLSv1.2' as const,
|
||||||
|
};
|
||||||
|
|
||||||
this.https = new https.Agent({
|
this.httpsNative = new https.Agent(agentOption);
|
||||||
keepAlive: true,
|
|
||||||
keepAliveMsecs: 30 * 1000,
|
this.https = new HttpsRequestServiceAgent(config, agentOption);
|
||||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
|
||||||
localAddress: config.outgoingAddress,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||||
|
|
||||||
this.httpAgent = config.proxy
|
|
||||||
? new HttpProxyAgent({
|
|
||||||
keepAlive: true,
|
|
||||||
keepAliveMsecs: 30 * 1000,
|
|
||||||
maxSockets,
|
|
||||||
maxFreeSockets: 256,
|
|
||||||
scheduling: 'lifo',
|
|
||||||
proxy: config.proxy,
|
|
||||||
localAddress: config.outgoingAddress,
|
|
||||||
})
|
|
||||||
: this.http;
|
|
||||||
|
|
||||||
this.httpsAgent = config.proxy
|
this.httpsAgent = config.proxy
|
||||||
? new HttpsProxyAgent({
|
? new HttpsProxyAgent({
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
|
@ -103,16 +170,23 @@ export class HttpRequestService {
|
||||||
* @param bypassProxy Allways bypass proxy
|
* @param bypassProxy Allways bypass proxy
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
public getAgentByUrl(url: URL, bypassProxy = false): https.Agent {
|
||||||
|
if (url.protocol && url.protocol !== 'https:') {
|
||||||
|
throw new Error('Invalid protocol');
|
||||||
|
}
|
||||||
|
url.protocol = 'https:';
|
||||||
|
if (url.port && url.port !== '443') {
|
||||||
|
throw new Error('Invalid port');
|
||||||
|
}
|
||||||
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||||
return url.protocol === 'http:' ? this.http : this.https;
|
return this.https;
|
||||||
} else {
|
} else {
|
||||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
return this.httpsAgent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getActivityJson(url: string): Promise<IObject> {
|
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -120,16 +194,22 @@ export class HttpRequestService {
|
||||||
},
|
},
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
size: 1024 * 256,
|
size: 1024 * 256,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
}, {
|
}, {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
validators: [validateContentTypeSetAsActivityPub],
|
validators: [validateContentTypeSetAsActivityPub],
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.json() as IObject;
|
const finalUrl = res.url; // redirects may have been involved
|
||||||
|
const activity = await res.json() as IObject;
|
||||||
|
|
||||||
|
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||||
|
|
||||||
|
return activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
|
@ -137,19 +217,21 @@ export class HttpRequestService {
|
||||||
}, headers ?? {}),
|
}, headers ?? {}),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
size: 1024 * 256,
|
size: 1024 * 256,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.json() as T;
|
return await res.json() as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
Accept: accept,
|
Accept: accept,
|
||||||
}, headers ?? {}),
|
}, headers ?? {}),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.text();
|
return await res.text();
|
||||||
|
@ -164,6 +246,7 @@ export class HttpRequestService {
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
size?: number,
|
size?: number,
|
||||||
|
isLocalAddressAllowed?: boolean,
|
||||||
} = {},
|
} = {},
|
||||||
extra: HttpRequestSendOptions = {
|
extra: HttpRequestSendOptions = {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
|
@ -177,6 +260,16 @@ export class HttpRequestService {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
|
const urlParsed = new URL(url);
|
||||||
|
|
||||||
|
if (urlParsed.protocol !== 'https:') {
|
||||||
|
throw new Error('Invalid protocol');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlParsed.port && urlParsed.port !== '443') {
|
||||||
|
throw new Error('Invalid port');
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: args.method ?? 'GET',
|
method: args.method ?? 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -185,7 +278,7 @@ export class HttpRequestService {
|
||||||
},
|
},
|
||||||
body: args.body,
|
body: args.body,
|
||||||
size: args.size ?? 10 * 1024 * 1024,
|
size: args.size ?? 10 * 1024 * 1024,
|
||||||
agent: (url) => this.getAgentByUrl(url),
|
agent: (url) => this.getAgentByUrl(url, false),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,9 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImageProcessingService {
|
// Prevent accidental import by upstream merge
|
||||||
|
// eslint-disable-next-line
|
||||||
|
export class __YUME_PRIVATE_ImageProcessingService {
|
||||||
constructor(
|
constructor(
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { setImmediate } from 'node:timers/promises';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
|
import * as Bull from 'bullmq';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||||
|
@ -56,6 +57,7 @@ import { isReply } from '@/misc/is-reply.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
|
@ -217,6 +219,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
private instanceChart: InstanceChart,
|
private instanceChart: InstanceChart,
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private userBlockingService: UserBlockingService,
|
private userBlockingService: UserBlockingService,
|
||||||
|
private cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||||
}
|
}
|
||||||
|
@ -291,7 +294,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
case 'followers':
|
case 'followers':
|
||||||
// 他人のfollowers noteはreject
|
// 他人のfollowers noteはreject
|
||||||
if (data.renote.userId !== user.id) {
|
if (data.renote.userId !== user.id) {
|
||||||
throw new Error('Renote target is not public or home');
|
throw new Bull.UnrecoverableError('Renote target is not public or home');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renote対象がfollowersならfollowersにする
|
// Renote対象がfollowersならfollowersにする
|
||||||
|
@ -299,7 +302,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
break;
|
break;
|
||||||
case 'specified':
|
case 'specified':
|
||||||
// specified / direct noteはreject
|
// specified / direct noteはreject
|
||||||
throw new Error('Renote target is not public or home');
|
throw new Bull.UnrecoverableError('Renote target is not public or home');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -543,13 +546,21 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
this.followingsRepository.findBy({
|
this.followingsRepository.findBy({
|
||||||
followeeId: user.id,
|
followeeId: user.id,
|
||||||
notify: 'normal',
|
notify: 'normal',
|
||||||
}).then(followings => {
|
}).then(async followings => {
|
||||||
if (note.visibility !== 'specified') {
|
if (note.visibility !== 'specified') {
|
||||||
|
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
||||||
for (const following of followings) {
|
for (const following of followings) {
|
||||||
// TODO: ワードミュート考慮
|
// TODO: ワードミュート考慮
|
||||||
this.notificationService.createNotification(following.followerId, 'note', {
|
let isRenoteMuted = false;
|
||||||
noteId: note.id,
|
if (isPureRenote) {
|
||||||
}, user.id);
|
const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
|
||||||
|
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
|
||||||
|
}
|
||||||
|
if (!isRenoteMuted) {
|
||||||
|
this.notificationService.createNotification(following.followerId, 'note', {
|
||||||
|
noteId: note.id,
|
||||||
|
}, user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -21,6 +21,13 @@ import type { Config } from '@/config.js';
|
||||||
import { UserListService } from '@/core/UserListService.js';
|
import { UserListService } from '@/core/UserListService.js';
|
||||||
import type { FilterUnionByProperty } from '@/types.js';
|
import type { FilterUnionByProperty } from '@/types.js';
|
||||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
|
import { metricCounter } from '@/server/api/MetricsService.js';
|
||||||
|
|
||||||
|
const mNotificationsCreated = metricCounter({
|
||||||
|
name: 'misskey_notifications_created',
|
||||||
|
help: 'Notifications created',
|
||||||
|
labelNames: ['event_type'],
|
||||||
|
});
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService implements OnApplicationShutdown {
|
export class NotificationService implements OnApplicationShutdown {
|
||||||
|
@ -165,6 +172,8 @@ export class NotificationService implements OnApplicationShutdown {
|
||||||
|
|
||||||
if (packed == null) return null;
|
if (packed == null) return null;
|
||||||
|
|
||||||
|
mNotificationsCreated?.inc({ event_type: notification.type });
|
||||||
|
|
||||||
// Publish notification event
|
// Publish notification event
|
||||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,19 @@ import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||||
import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RedisKVCache } from '@/misc/cache.js';
|
import { RedisKVCache } from '@/misc/cache.js';
|
||||||
|
import { metricCounter } from '@/server/api/MetricsService.js';
|
||||||
|
|
||||||
|
const mWebPushCreated = metricCounter({
|
||||||
|
name: 'misskey_webpush_created',
|
||||||
|
help: 'WebPush event',
|
||||||
|
labelNames: ['event_type'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mWebPushError = metricCounter({
|
||||||
|
name: 'misskey_webpush_error',
|
||||||
|
help: 'WebPush error',
|
||||||
|
labelNames: ['event_type', 'status'],
|
||||||
|
});
|
||||||
|
|
||||||
// Defined also packages/sw/types.ts#L13
|
// Defined also packages/sw/types.ts#L13
|
||||||
type PushNotificationsTypes = {
|
type PushNotificationsTypes = {
|
||||||
|
@ -95,6 +108,8 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mWebPushCreated?.inc({ event_type: type });
|
||||||
|
|
||||||
push.sendNotification(pushSubscription, JSON.stringify({
|
push.sendNotification(pushSubscription, JSON.stringify({
|
||||||
type,
|
type,
|
||||||
body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body,
|
body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body,
|
||||||
|
@ -116,6 +131,8 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
this.refreshCache(userId);
|
this.refreshCache(userId);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
mWebPushError?.inc({ event_type: type, status: err.statusCode || 'unknown' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
SystemWebhookDeliverJobData,
|
SystemWebhookDeliverJobData,
|
||||||
} from '../queue/types.js';
|
} from '../queue/types.js';
|
||||||
import type { Provider } from '@nestjs/common';
|
import type { Provider } from '@nestjs/common';
|
||||||
|
import { mActiveJobs, mDelayedJobs, mFailedJobs, mJobBlockedCounter, mWaitingJobs } from '@/queue/metrics.js';
|
||||||
|
|
||||||
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
|
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
|
||||||
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
|
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
|
||||||
|
@ -29,57 +30,74 @@ export type ObjectStorageQueue = Bull.Queue;
|
||||||
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||||
|
|
||||||
|
function withMetrics<T>(queue: Bull.Queue<T>): Bull.Queue<T> {
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
setInterval(async () => {
|
||||||
|
mActiveJobs?.set({ queue: queue.name }, await queue.getActiveCount());
|
||||||
|
mDelayedJobs?.set({ queue: queue.name }, await queue.getDelayedCount());
|
||||||
|
mWaitingJobs?.set({ queue: queue.name }, await queue.getWaitingCount());
|
||||||
|
mFailedJobs?.set({ queue: queue.name }, await queue.getFailedCount());
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
queue.on('waiting', () => {
|
||||||
|
mJobBlockedCounter?.inc({ queue: queue.name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
const $system: Provider = {
|
const $system: Provider = {
|
||||||
provide: 'queue:system',
|
provide: 'queue:system',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $endedPollNotification: Provider = {
|
const $endedPollNotification: Provider = {
|
||||||
provide: 'queue:endedPollNotification',
|
provide: 'queue:endedPollNotification',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $deliver: Provider = {
|
const $deliver: Provider = {
|
||||||
provide: 'queue:deliver',
|
provide: 'queue:deliver',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $inbox: Provider = {
|
const $inbox: Provider = {
|
||||||
provide: 'queue:inbox',
|
provide: 'queue:inbox',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $db: Provider = {
|
const $db: Provider = {
|
||||||
provide: 'queue:db',
|
provide: 'queue:db',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $relationship: Provider = {
|
const $relationship: Provider = {
|
||||||
provide: 'queue:relationship',
|
provide: 'queue:relationship',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $objectStorage: Provider = {
|
const $objectStorage: Provider = {
|
||||||
provide: 'queue:objectStorage',
|
provide: 'queue:objectStorage',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $userWebhookDeliver: Provider = {
|
const $userWebhookDeliver: Provider = {
|
||||||
provide: 'queue:userWebhookDeliver',
|
provide: 'queue:userWebhookDeliver',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
const $systemWebhookDeliver: Provider = {
|
const $systemWebhookDeliver: Provider = {
|
||||||
provide: 'queue:systemWebhookDeliver',
|
provide: 'queue:systemWebhookDeliver',
|
||||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)),
|
useFactory: (config: Config) => withMetrics(new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER))),
|
||||||
inject: [DI.config],
|
inject: [DI.config],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,9 @@ import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||||
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||||
|
import { UserWebhookPayload, UserWebhookService } from './UserWebhookService.js';
|
||||||
|
import { QueueService } from './QueueService.js';
|
||||||
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
|
|
||||||
const FALLBACK = '\u2764';
|
const FALLBACK = '\u2764';
|
||||||
|
|
||||||
|
@ -94,6 +97,8 @@ export class ReactionService {
|
||||||
private reactionsBufferingService: ReactionsBufferingService,
|
private reactionsBufferingService: ReactionsBufferingService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private featuredService: FeaturedService,
|
private featuredService: FeaturedService,
|
||||||
|
private queueService: QueueService,
|
||||||
|
private webhookService: UserWebhookService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
private apDeliverManagerService: ApDeliverManagerService,
|
private apDeliverManagerService: ApDeliverManagerService,
|
||||||
|
@ -254,12 +259,33 @@ export class ReactionService {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
// リアクションされたユーザーがローカルユーザーなら通知を作成してWebhookを送信
|
||||||
if (note.userHost === null) {
|
if (note.userHost === null) {
|
||||||
this.notificationService.createNotification(note.userId, 'reaction', {
|
this.notificationService.createNotification(note.userId, 'reaction', {
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
reaction: reaction,
|
reaction: reaction,
|
||||||
}, user.id);
|
}, user.id);
|
||||||
|
|
||||||
|
this.webhookService.getActiveWebhooks().then(async webhooks => {
|
||||||
|
webhooks = webhooks.filter(x => x.userId === note.userId && x.on.includes('reaction'));
|
||||||
|
if (webhooks.length === 0) return;
|
||||||
|
|
||||||
|
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
|
||||||
|
const userObj = await this.userEntityService.pack(user.id, null, { schema: 'UserLite' });
|
||||||
|
|
||||||
|
const payload: UserWebhookPayload<'reaction'> = {
|
||||||
|
note: noteObj,
|
||||||
|
reaction: {
|
||||||
|
id: record.id,
|
||||||
|
user: userObj,
|
||||||
|
reaction: reaction,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
this.queueService.userWebhookDeliver(webhook, 'reaction', payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//#region 配信
|
//#region 配信
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
|
||||||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { ApResolverService } from './activitypub/ApResolverService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RemoteUserResolveService {
|
export class RemoteUserResolveService {
|
||||||
|
@ -35,6 +36,7 @@ export class RemoteUserResolveService {
|
||||||
private remoteLoggerService: RemoteLoggerService,
|
private remoteLoggerService: RemoteLoggerService,
|
||||||
private apDbResolverService: ApDbResolverService,
|
private apDbResolverService: ApDbResolverService,
|
||||||
private apPersonService: ApPersonService,
|
private apPersonService: ApPersonService,
|
||||||
|
private apResolverService: ApResolverService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user');
|
this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user');
|
||||||
}
|
}
|
||||||
|
@ -91,7 +93,7 @@ export class RemoteUserResolveService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
|
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
|
||||||
return await this.apPersonService.createPerson(self.href);
|
return await this.apPersonService.createPerson(self.href, this.apResolverService.createResolver());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
|
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
|
||||||
|
|
|
@ -488,6 +488,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||||
return ids.length > 0
|
return ids.length > 0
|
||||||
? await this.usersRepository.findBy({
|
? await this.usersRepository.findBy({
|
||||||
id: In(ids),
|
id: In(ids),
|
||||||
|
isDeleted: false,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { Brackets, In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
@ -215,7 +215,10 @@ export class SearchService {
|
||||||
}
|
}
|
||||||
|
|
||||||
query
|
query
|
||||||
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
|
.andWhere(new Brackets((qb => {
|
||||||
|
qb.where('note.text &@~ :q', { q: sqlLikeEscape(q) })
|
||||||
|
.orWhere('note.cw &@~ :q', { q: sqlLikeEscape(q) });
|
||||||
|
})))
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
|
|
@ -14,6 +14,14 @@ import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
|
|
||||||
export type UserWebhookPayload<T extends WebhookEventTypes> =
|
export type UserWebhookPayload<T extends WebhookEventTypes> =
|
||||||
|
T extends 'reaction' ? {
|
||||||
|
reaction: {
|
||||||
|
id: string,
|
||||||
|
user: Packed<'UserLite'>,
|
||||||
|
reaction: string,
|
||||||
|
}
|
||||||
|
note: Packed<'Note'>,
|
||||||
|
}:
|
||||||
T extends 'note' | 'reply' | 'renote' |'mention' ? {
|
T extends 'note' | 'reply' | 'renote' |'mention' ? {
|
||||||
note: Packed<'Note'>,
|
note: Packed<'Note'>,
|
||||||
} :
|
} :
|
||||||
|
|
|
@ -7,19 +7,21 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import FFmpeg from 'fluent-ffmpeg';
|
import FFmpeg from 'fluent-ffmpeg';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||||
import { createTempDir } from '@/misc/create-temp.js';
|
import { createTempDir } from '@/misc/create-temp.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { appendQuery, query } from '@/misc/prelude/url.js';
|
import { appendQuery, query } from '@/misc/prelude/url.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoProcessingService {
|
// Prevent accidental import by upstream merge
|
||||||
|
// eslint-disable-next-line
|
||||||
|
export class __YUME_PRIVATE_VideoProcessingService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
private imageProcessingService: ImageProcessingService,
|
private imageProcessingService: __YUME_PRIVATE_ImageProcessingService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -83,7 +83,11 @@ export class WebAuthnService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
|
public async verifyRegistration(
|
||||||
|
userId: MiUser['id'],
|
||||||
|
response: RegistrationResponseJSON,
|
||||||
|
twoFactorOnly: boolean = false,
|
||||||
|
): Promise<{
|
||||||
credentialID: string;
|
credentialID: string;
|
||||||
credentialPublicKey: Uint8Array;
|
credentialPublicKey: Uint8Array;
|
||||||
attestationObject: Uint8Array;
|
attestationObject: Uint8Array;
|
||||||
|
@ -111,7 +115,7 @@ export class WebAuthnService {
|
||||||
expectedChallenge: challenge,
|
expectedChallenge: challenge,
|
||||||
expectedOrigin: relyingParty.origin,
|
expectedOrigin: relyingParty.origin,
|
||||||
expectedRPID: relyingParty.rpId,
|
expectedRPID: relyingParty.rpId,
|
||||||
requireUserVerification: true,
|
requireUserVerification: !twoFactorOnly,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -245,7 +249,11 @@ export class WebAuthnService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
public async verifyAuthentication(
|
||||||
|
userId: MiUser['id'],
|
||||||
|
response: AuthenticationResponseJSON,
|
||||||
|
twoFactorOnly: boolean = false,
|
||||||
|
): Promise<boolean> {
|
||||||
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
||||||
|
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
|
@ -302,7 +310,7 @@ export class WebAuthnService {
|
||||||
counter: key.counter,
|
counter: key.counter,
|
||||||
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
|
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
|
||||||
},
|
},
|
||||||
requireUserVerification: true,
|
requireUserVerification: !twoFactorOnly,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
@ -253,6 +253,20 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateDummyReactionPayload(note_override?: Partial<MiNote>): UserWebhookPayload<'reaction'> {
|
||||||
|
const dummyNote = generateDummyNote(note_override);
|
||||||
|
const dummyReaction = {
|
||||||
|
id: 'dummy-reaction-1',
|
||||||
|
user: toPackedUserLite(generateDummyUser()),
|
||||||
|
reaction: 'test_reaction',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
note: toPackedNote(dummyNote),
|
||||||
|
reaction: dummyReaction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const dummyUser1 = generateDummyUser();
|
const dummyUser1 = generateDummyUser();
|
||||||
const dummyUser2 = generateDummyUser({
|
const dummyUser2 = generateDummyUser({
|
||||||
id: 'dummy-user-2',
|
id: 'dummy-user-2',
|
||||||
|
@ -279,6 +293,10 @@ const dummyUser3 = generateDummyUser({
|
||||||
notesCount: 15900,
|
notesCount: 15900,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function wrapBodyEnum<T extends string, U>(tag: T, body: U): { [K in T]: U } {
|
||||||
|
return { [tag]: body } as { [K in T]: U };
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebhookTestService {
|
export class WebhookTestService {
|
||||||
public static NoSuchWebhookError = class extends Error {
|
public static NoSuchWebhookError = class extends Error {
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
import { getApId } from './type.js';
|
import { getApId } from './type.js';
|
||||||
import { ApPersonService } from './models/ApPersonService.js';
|
import { ApPersonService } from './models/ApPersonService.js';
|
||||||
import type { IObject } from './type.js';
|
import type { IObject } from './type.js';
|
||||||
|
import { toASCII } from 'node:punycode';
|
||||||
|
|
||||||
export type UriParseResult = {
|
export type UriParseResult = {
|
||||||
/** wether the URI was generated by us */
|
/** wether the URI was generated by us */
|
||||||
|
@ -63,7 +64,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||||
const separator = '/';
|
const separator = '/';
|
||||||
|
|
||||||
const uri = new URL(getApId(value));
|
const uri = new URL(getApId(value));
|
||||||
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
|
if (toASCII(uri.host) !== toASCII(this.config.host)) {
|
||||||
|
return { local: false, uri: uri.href };
|
||||||
|
}
|
||||||
|
|
||||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
|
@ -28,7 +29,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
import type { MiRemoteUser } from '@/models/User.js';
|
import type { MiRemoteUser } from '@/models/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
import { getApHrefNullable, getApId, getApIds, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPost, isTombstone, validActor, validPost, yumeDowncastAccept, yumeDowncastAdd, yumeDowncastAnnounce, yumeDowncastBlock, yumeDowncastCreate, yumeDowncastDelete, yumeDowncastFlag, yumeDowncastFollow, yumeDowncastLike, yumeDowncastMove, yumeDowncastReject, yumeDowncastRemove, yumeDowncastUndo, yumeDowncastUpdate } from './type.js';
|
||||||
import { ApNoteService } from './models/ApNoteService.js';
|
import { ApNoteService } from './models/ApNoteService.js';
|
||||||
import { ApLoggerService } from './ApLoggerService.js';
|
import { ApLoggerService } from './ApLoggerService.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
|
@ -38,6 +39,13 @@ import { ApPersonService } from './models/ApPersonService.js';
|
||||||
import { ApQuestionService } from './models/ApQuestionService.js';
|
import { ApQuestionService } from './models/ApQuestionService.js';
|
||||||
import type { Resolver } from './ApResolverService.js';
|
import type { Resolver } from './ApResolverService.js';
|
||||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
|
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
|
||||||
|
import { metricCounter } from '@/server/api/MetricsService.js';
|
||||||
|
|
||||||
|
const mInboxReceived = metricCounter({
|
||||||
|
name: 'misskey_ap_inbox_received_total',
|
||||||
|
help: 'Total number of activities received by AP inbox',
|
||||||
|
labelNames: ['host', 'type'],
|
||||||
|
});
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApInboxService {
|
export class ApInboxService {
|
||||||
|
@ -130,37 +138,92 @@ export class ApInboxService {
|
||||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
||||||
if (actor.isSuspended) return;
|
if (actor.isSuspended) return;
|
||||||
|
|
||||||
if (isCreate(activity)) {
|
const create = yumeDowncastCreate(activity);
|
||||||
return await this.create(actor, activity);
|
if (create) {
|
||||||
} else if (isDelete(activity)) {
|
mInboxReceived?.inc({ host: actor.host, type: 'create' });
|
||||||
return await this.delete(actor, activity);
|
return await this.create(actor, create);
|
||||||
} else if (isUpdate(activity)) {
|
|
||||||
return await this.update(actor, activity);
|
|
||||||
} else if (isFollow(activity)) {
|
|
||||||
return await this.follow(actor, activity);
|
|
||||||
} else if (isAccept(activity)) {
|
|
||||||
return await this.accept(actor, activity);
|
|
||||||
} else if (isReject(activity)) {
|
|
||||||
return await this.reject(actor, activity);
|
|
||||||
} else if (isAdd(activity)) {
|
|
||||||
return await this.add(actor, activity);
|
|
||||||
} else if (isRemove(activity)) {
|
|
||||||
return await this.remove(actor, activity);
|
|
||||||
} else if (isAnnounce(activity)) {
|
|
||||||
return await this.announce(actor, activity);
|
|
||||||
} else if (isLike(activity)) {
|
|
||||||
return await this.like(actor, activity);
|
|
||||||
} else if (isUndo(activity)) {
|
|
||||||
return await this.undo(actor, activity);
|
|
||||||
} else if (isBlock(activity)) {
|
|
||||||
return await this.block(actor, activity);
|
|
||||||
} else if (isFlag(activity)) {
|
|
||||||
return await this.flag(actor, activity);
|
|
||||||
} else if (isMove(activity)) {
|
|
||||||
return await this.move(actor, activity);
|
|
||||||
} else {
|
|
||||||
return `unrecognized activity type: ${activity.type}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const update = yumeDowncastUpdate(activity);
|
||||||
|
if (update) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'update' });
|
||||||
|
return await this.update(actor, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = yumeDowncastDelete(activity);
|
||||||
|
if (del) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'delete' });
|
||||||
|
return await this.delete(actor, del);
|
||||||
|
}
|
||||||
|
|
||||||
|
const follow = yumeDowncastFollow(activity);
|
||||||
|
if (follow) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'follow' });
|
||||||
|
return await this.follow(actor, follow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = yumeDowncastAccept(activity);
|
||||||
|
if (accept) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'accept' });
|
||||||
|
return await this.accept(actor, accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reject = yumeDowncastReject(activity);
|
||||||
|
if (reject) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'reject' });
|
||||||
|
return await this.reject(actor, reject);
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = yumeDowncastAdd(activity);
|
||||||
|
if (add) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'add' });
|
||||||
|
return await this.add(actor, add);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = yumeDowncastRemove(activity);
|
||||||
|
if (remove) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'remove' });
|
||||||
|
return await this.remove(actor, remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
const announce = yumeDowncastAnnounce(activity);
|
||||||
|
if (announce) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'announce' });
|
||||||
|
return await this.announce(actor, announce);
|
||||||
|
}
|
||||||
|
|
||||||
|
const like = yumeDowncastLike(activity);
|
||||||
|
if (like) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'like' });
|
||||||
|
return await this.like(actor, like);
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = yumeDowncastMove(activity);
|
||||||
|
if (move) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'move' });
|
||||||
|
return await this.move(actor, move);
|
||||||
|
}
|
||||||
|
|
||||||
|
const undo = yumeDowncastUndo(activity);
|
||||||
|
if (undo) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'undo' });
|
||||||
|
return await this.undo(actor, undo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = yumeDowncastBlock(activity);
|
||||||
|
if (block) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'block' });
|
||||||
|
return await this.block(actor, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flag = yumeDowncastFlag(activity);
|
||||||
|
if (flag) {
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'flag' });
|
||||||
|
return await this.flag(actor, flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
mInboxReceived?.inc({ host: actor.host, type: 'unknown' });
|
||||||
|
return `unrecognized activity type: ${activity.type}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -211,7 +274,8 @@ export class ApInboxService {
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFollow(object)) return await this.acceptFollow(actor, object);
|
const follow = yumeDowncastFollow(object);
|
||||||
|
if (follow) return await this.acceptFollow(actor, follow);
|
||||||
|
|
||||||
return `skip: Unknown Accept type: ${getApType(object)}`;
|
return `skip: Unknown Accept type: ${getApType(object)}`;
|
||||||
}
|
}
|
||||||
|
@ -423,7 +487,7 @@ export class ApInboxService {
|
||||||
const exist = await this.apNoteService.fetchNote(note);
|
const exist = await this.apNoteService.fetchNote(note);
|
||||||
if (exist) return 'skip: note exists';
|
if (exist) return 'skip: note exists';
|
||||||
|
|
||||||
await this.apNoteService.createNote(note, resolver, silent);
|
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||||
return 'ok';
|
return 'ok';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof StatusError && !err.isRetryable) {
|
if (err instanceof StatusError && !err.isRetryable) {
|
||||||
|
@ -469,9 +533,9 @@ export class ApInboxService {
|
||||||
formerType = 'Note';
|
formerType = 'Note';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validPost.includes(formerType)) {
|
if (validPost?.includes(formerType)) {
|
||||||
return await this.deleteNote(actor, uri);
|
return await this.deleteNote(actor, uri);
|
||||||
} else if (validActor.includes(formerType)) {
|
} else if (validActor?.includes(formerType)) {
|
||||||
return await this.deleteActor(actor, uri);
|
return await this.deleteActor(actor, uri);
|
||||||
} else {
|
} else {
|
||||||
return `Unknown type ${formerType}`;
|
return `Unknown type ${formerType}`;
|
||||||
|
@ -486,19 +550,12 @@ export class ApInboxService {
|
||||||
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({ id: actor.id });
|
if (!(await this.usersRepository.update({ id: actor.id, isDeleted: false }, { isDeleted: true })).affected) {
|
||||||
if (user == null) {
|
|
||||||
return 'skip: actor not found';
|
|
||||||
} else if (user.isDeleted) {
|
|
||||||
return 'skip: already deleted';
|
return 'skip: already deleted';
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await this.queueService.createDeleteAccountJob(actor);
|
const job = await this.queueService.createDeleteAccountJob(actor);
|
||||||
|
|
||||||
await this.usersRepository.update(actor.id, {
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id });
|
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id });
|
||||||
|
|
||||||
return `ok: queued ${job.name} ${job.id}`;
|
return `ok: queued ${job.name} ${job.id}`;
|
||||||
|
@ -567,7 +624,8 @@ export class ApInboxService {
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFollow(object)) return await this.rejectFollow(actor, object);
|
const follow = yumeDowncastFollow(object);
|
||||||
|
if (follow) return await this.rejectFollow(actor, follow);
|
||||||
|
|
||||||
return `skip: Unknown Reject type: ${getApType(object)}`;
|
return `skip: Unknown Reject type: ${getApType(object)}`;
|
||||||
}
|
}
|
||||||
|
@ -634,11 +692,20 @@ export class ApInboxService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// don't queue because the sender may attempt again when timeout
|
// don't queue because the sender may attempt again when timeout
|
||||||
if (isFollow(object)) return await this.undoFollow(actor, object);
|
const follow = yumeDowncastFollow(object);
|
||||||
if (isBlock(object)) return await this.undoBlock(actor, object);
|
if (follow) return await this.undoFollow(actor, follow);
|
||||||
if (isLike(object)) return await this.undoLike(actor, object);
|
|
||||||
if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
|
const block = yumeDowncastBlock(object);
|
||||||
if (isAccept(object)) return await this.undoAccept(actor, object);
|
if (block) return await this.undoBlock(actor, block);
|
||||||
|
|
||||||
|
const like = yumeDowncastLike(object);
|
||||||
|
if (like) return await this.undoLike(actor, like);
|
||||||
|
|
||||||
|
const announce = yumeDowncastAnnounce(object);
|
||||||
|
if (announce) return await this.undoAnnounce(actor, announce);
|
||||||
|
|
||||||
|
const accept = yumeDowncastAccept(object);
|
||||||
|
if (accept) return await this.undoAccept(actor, accept);
|
||||||
|
|
||||||
return `skip: unknown object type ${getApType(object)}`;
|
return `skip: unknown object type ${getApType(object)}`;
|
||||||
}
|
}
|
||||||
|
@ -768,7 +835,7 @@ export class ApInboxService {
|
||||||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||||
return 'ok: Person updated';
|
return 'ok: Person updated';
|
||||||
} else if (getApType(object) === 'Question') {
|
} else if (getApType(object) === 'Question') {
|
||||||
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
|
await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err));
|
||||||
return 'ok: Question updated';
|
return 'ok: Question updated';
|
||||||
} else {
|
} else {
|
||||||
return `skip: Unknown type: ${getApType(object)}`;
|
return `skip: Unknown type: ${getApType(object)}`;
|
||||||
|
|
|
@ -30,7 +30,7 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { JsonLdService } from './JsonLdService.js';
|
import { JsonLdService } from './JsonLdService.js';
|
||||||
import { ApMfmService } from './ApMfmService.js';
|
import { ApMfmService } from './ApMfmService.js';
|
||||||
import { CONTEXT } from './misc/contexts.js';
|
import { CONTEXT } from './misc/contexts.js';
|
||||||
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
import { markOutgoing, type IAccept, type IActivity, type IAdd, type IAnnounce, type IApDocument, type IApEmoji, type IApHashtag, type IApImage, type IApMention, type IBlock, type ICreate, type IDelete, type IFlag, type IFollow, type IKey, type ILike, type IMove, type IObject, type IPost, type IQuestion, type IReject, type IRemove, type ITombstone, type IUndo, type IUpdate } from './type.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApRendererService {
|
export class ApRendererService {
|
||||||
|
@ -66,21 +66,21 @@ export class ApRendererService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderAccept(object: string | IObject, user: { id: MiUser['id']; host: null }): IAccept {
|
public renderAccept(object: string | IObject, user: { id: MiUser['id']; host: null }): IAccept {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Accept',
|
type: 'Accept',
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderAdd(user: MiLocalUser, target: string | IObject | undefined, object: string | IObject): IAdd {
|
public renderAdd(user: MiLocalUser, target: string | IObject | undefined, object: string | IObject): IAdd {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Add',
|
type: 'Add',
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
target,
|
target,
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -103,7 +103,7 @@ export class ApRendererService {
|
||||||
throw new Error('renderAnnounce: cannot render non-public note');
|
throw new Error('renderAnnounce: cannot render non-public note');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return markOutgoing({
|
||||||
id: `${this.config.url}/notes/${note.id}/activity`,
|
id: `${this.config.url}/notes/${note.id}/activity`,
|
||||||
actor: this.userEntityService.genLocalUserUri(note.userId),
|
actor: this.userEntityService.genLocalUserUri(note.userId),
|
||||||
type: 'Announce',
|
type: 'Announce',
|
||||||
|
@ -111,7 +111,7 @@ export class ApRendererService {
|
||||||
to,
|
to,
|
||||||
cc,
|
cc,
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -125,23 +125,23 @@ export class ApRendererService {
|
||||||
throw new Error('renderBlock: missing blockee uri');
|
throw new Error('renderBlock: missing blockee uri');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Block',
|
type: 'Block',
|
||||||
id: `${this.config.url}/blocks/${block.id}`,
|
id: `${this.config.url}/blocks/${block.id}`,
|
||||||
actor: this.userEntityService.genLocalUserUri(block.blockerId),
|
actor: this.userEntityService.genLocalUserUri(block.blockerId),
|
||||||
object: block.blockee.uri,
|
object: block.blockee.uri,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderCreate(object: IObject, note: MiNote): ICreate {
|
public renderCreate(object: IObject, note: MiNote): ICreate {
|
||||||
const activity: ICreate = {
|
const activity: ICreate = markOutgoing({
|
||||||
id: `${this.config.url}/notes/${note.id}/activity`,
|
id: `${this.config.url}/notes/${note.id}/activity`,
|
||||||
actor: this.userEntityService.genLocalUserUri(note.userId),
|
actor: this.userEntityService.genLocalUserUri(note.userId),
|
||||||
type: 'Create',
|
type: 'Create',
|
||||||
published: this.idService.parse(note.id).date.toISOString(),
|
published: this.idService.parse(note.id).date.toISOString(),
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
|
|
||||||
if (object.to) activity.to = object.to;
|
if (object.to) activity.to = object.to;
|
||||||
if (object.cc) activity.cc = object.cc;
|
if (object.cc) activity.cc = object.cc;
|
||||||
|
@ -151,28 +151,28 @@ export class ApRendererService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderDelete(object: IObject | string, user: { id: MiUser['id']; host: null }): IDelete {
|
public renderDelete(object: IObject | string, user: { id: MiUser['id']; host: null }): IDelete {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Delete',
|
type: 'Delete',
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
object,
|
object,
|
||||||
published: new Date().toISOString(),
|
published: new Date().toISOString(),
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderDocument(file: MiDriveFile): IApDocument {
|
public renderDocument(file: MiDriveFile): IApDocument {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Document',
|
type: 'Document',
|
||||||
mediaType: file.webpublicType ?? file.type,
|
mediaType: file.webpublicType ?? file.type,
|
||||||
url: this.driveFileEntityService.getPublicUrl(file),
|
url: this.driveFileEntityService.getPublicUrl(file),
|
||||||
name: file.comment,
|
name: file.comment,
|
||||||
sensitive: file.isSensitive,
|
sensitive: file.isSensitive,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderEmoji(emoji: MiEmoji): IApEmoji {
|
public renderEmoji(emoji: MiEmoji): IApEmoji {
|
||||||
return {
|
return markOutgoing( {
|
||||||
id: `${this.config.url}/emojis/${emoji.name}`,
|
id: `${this.config.url}/emojis/${emoji.name}`,
|
||||||
type: 'Emoji',
|
type: 'Emoji',
|
||||||
name: `:${emoji.name}:`,
|
name: `:${emoji.name}:`,
|
||||||
|
@ -183,28 +183,28 @@ export class ApRendererService {
|
||||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||||
url: emoji.publicUrl || emoji.originalUrl,
|
url: emoji.publicUrl || emoji.originalUrl,
|
||||||
},
|
},
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// to anonymise reporters, the reporting actor must be a system user
|
// to anonymise reporters, the reporting actor must be a system user
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderFlag(user: MiLocalUser, object: IObject | string, content: string): IFlag {
|
public renderFlag(user: MiLocalUser, object: IObject | string, content: string): IFlag {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Flag',
|
type: 'Flag',
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
content,
|
content,
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderFollowRelay(relay: MiRelay, relayActor: MiLocalUser): IFollow {
|
public renderFollowRelay(relay: MiRelay, relayActor: MiLocalUser): IFollow {
|
||||||
return {
|
return markOutgoing({
|
||||||
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
|
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
|
||||||
type: 'Follow',
|
type: 'Follow',
|
||||||
actor: this.userEntityService.genLocalUserUri(relayActor.id),
|
actor: this.userEntityService.genLocalUserUri(relayActor.id),
|
||||||
object: 'https://www.w3.org/ns/activitystreams#Public',
|
object: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -223,36 +223,36 @@ export class ApRendererService {
|
||||||
followee: MiPartialLocalUser | MiPartialRemoteUser,
|
followee: MiPartialLocalUser | MiPartialRemoteUser,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
): IFollow {
|
): IFollow {
|
||||||
return {
|
return markOutgoing({
|
||||||
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
|
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
|
||||||
type: 'Follow',
|
type: 'Follow',
|
||||||
actor: this.userEntityService.getUserUri(follower),
|
actor: this.userEntityService.getUserUri(follower),
|
||||||
object: this.userEntityService.getUserUri(followee),
|
object: this.userEntityService.getUserUri(followee),
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderHashtag(tag: string): IApHashtag {
|
public renderHashtag(tag: string): IApHashtag {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Hashtag',
|
type: 'Hashtag',
|
||||||
href: `${this.config.url}/tags/${encodeURIComponent(tag)}`,
|
href: `${this.config.url}/tags/${encodeURIComponent(tag)}`,
|
||||||
name: `#${tag}`,
|
name: `#${tag}`,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderImage(file: MiDriveFile): IApImage {
|
public renderImage(file: MiDriveFile): IApImage {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Image',
|
type: 'Image',
|
||||||
url: this.driveFileEntityService.getPublicUrl(file),
|
url: this.driveFileEntityService.getPublicUrl(file),
|
||||||
sensitive: file.isSensitive,
|
sensitive: file.isSensitive,
|
||||||
name: file.comment,
|
name: file.comment,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
|
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
|
||||||
return {
|
return markOutgoing({
|
||||||
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
|
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
|
||||||
type: 'Key',
|
type: 'Key',
|
||||||
owner: this.userEntityService.genLocalUserUri(user.id),
|
owner: this.userEntityService.genLocalUserUri(user.id),
|
||||||
|
@ -260,21 +260,21 @@ export class ApRendererService {
|
||||||
type: 'spki',
|
type: 'spki',
|
||||||
format: 'pem',
|
format: 'pem',
|
||||||
}),
|
}),
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }): Promise<ILike> {
|
public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }): Promise<ILike> {
|
||||||
const reaction = noteReaction.reaction;
|
const reaction = noteReaction.reaction;
|
||||||
|
|
||||||
const object: ILike = {
|
const object: ILike = markOutgoing({
|
||||||
type: 'Like',
|
type: 'Like',
|
||||||
id: `${this.config.url}/likes/${noteReaction.id}`,
|
id: `${this.config.url}/likes/${noteReaction.id}`,
|
||||||
actor: `${this.config.url}/users/${noteReaction.userId}`,
|
actor: `${this.config.url}/users/${noteReaction.userId}`,
|
||||||
object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`,
|
object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`,
|
||||||
content: reaction,
|
content: reaction,
|
||||||
_misskey_reaction: reaction,
|
_misskey_reaction: reaction,
|
||||||
};
|
}, undefined);
|
||||||
|
|
||||||
if (reaction.startsWith(':')) {
|
if (reaction.startsWith(':')) {
|
||||||
const name = reaction.replaceAll(':', '');
|
const name = reaction.replaceAll(':', '');
|
||||||
|
@ -288,11 +288,11 @@ export class ApRendererService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderMention(mention: MiPartialLocalUser | MiPartialRemoteUser): IApMention {
|
public renderMention(mention: MiPartialLocalUser | MiPartialRemoteUser): IApMention {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Mention',
|
type: 'Mention',
|
||||||
href: this.userEntityService.getUserUri(mention),
|
href: this.userEntityService.getUserUri(mention),
|
||||||
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as MiLocalUser).username}`,
|
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as MiLocalUser).username}`,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -302,13 +302,13 @@ export class ApRendererService {
|
||||||
): IMove {
|
): IMove {
|
||||||
const actor = this.userEntityService.getUserUri(src);
|
const actor = this.userEntityService.getUserUri(src);
|
||||||
const target = this.userEntityService.getUserUri(dst);
|
const target = this.userEntityService.getUserUri(dst);
|
||||||
return {
|
return markOutgoing({
|
||||||
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
|
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
|
||||||
actor,
|
actor,
|
||||||
type: 'Move',
|
type: 'Move',
|
||||||
object: actor,
|
object: actor,
|
||||||
target,
|
target,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -422,7 +422,7 @@ export class ApRendererService {
|
||||||
})),
|
})),
|
||||||
} as const : {};
|
} as const : {};
|
||||||
|
|
||||||
return {
|
return markOutgoing({
|
||||||
id: `${this.config.url}/notes/${note.id}`,
|
id: `${this.config.url}/notes/${note.id}`,
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
attributedTo,
|
attributedTo,
|
||||||
|
@ -445,7 +445,7 @@ export class ApRendererService {
|
||||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||||
tag,
|
tag,
|
||||||
...asPoll,
|
...asPoll,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -529,7 +529,7 @@ export class ApRendererService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion {
|
public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Question',
|
type: 'Question',
|
||||||
id: `${this.config.url}/questions/${note.id}`,
|
id: `${this.config.url}/questions/${note.id}`,
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
|
@ -542,78 +542,78 @@ export class ApRendererService {
|
||||||
totalItems: poll.votes[i],
|
totalItems: poll.votes[i],
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
}, 'question');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderReject(object: string | IObject, user: { id: MiUser['id'] }): IReject {
|
public renderReject(object: string | IObject, user: { id: MiUser['id'] }): IReject {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Reject',
|
type: 'Reject',
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderRemove(user: { id: MiUser['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove {
|
public renderRemove(user: { id: MiUser['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove {
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Remove',
|
type: 'Remove',
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
target,
|
target,
|
||||||
object,
|
object,
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderTombstone(id: string): ITombstone {
|
public renderTombstone(id: string): ITombstone {
|
||||||
return {
|
return markOutgoing({
|
||||||
id,
|
id,
|
||||||
type: 'Tombstone',
|
type: 'Tombstone',
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
|
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
|
||||||
const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
|
const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
|
||||||
|
|
||||||
return {
|
return markOutgoing({
|
||||||
type: 'Undo',
|
type: 'Undo',
|
||||||
...(id ? { id } : {}),
|
...(id ? { id } : {}),
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
object,
|
object,
|
||||||
published: new Date().toISOString(),
|
published: new Date().toISOString(),
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
|
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
|
||||||
return {
|
return markOutgoing( {
|
||||||
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
|
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
type: 'Update',
|
type: 'Update',
|
||||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
object,
|
object,
|
||||||
published: new Date().toISOString(),
|
published: new Date().toISOString(),
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
|
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
|
||||||
return {
|
return markOutgoing({
|
||||||
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
|
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
|
||||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||||
type: 'Create',
|
type: 'Create',
|
||||||
to: [pollOwner.uri],
|
to: [pollOwner.uri],
|
||||||
published: new Date().toISOString(),
|
published: new Date().toISOString(),
|
||||||
object: {
|
object: markOutgoing({
|
||||||
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
|
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
attributedTo: this.userEntityService.genLocalUserUri(user.id),
|
attributedTo: this.userEntityService.genLocalUserUri(user.id),
|
||||||
to: [pollOwner.uri],
|
to: [pollOwner.uri],
|
||||||
inReplyTo: note.uri,
|
inReplyTo: note.uri,
|
||||||
name: poll.choices[vote.choice],
|
name: poll.choices[vote.choice],
|
||||||
},
|
}, undefined),
|
||||||
};
|
}, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
@ -11,11 +11,14 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
|
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
|
import type { IObject } from './type.js';
|
||||||
|
|
||||||
type Request = {
|
type Request = {
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -145,6 +148,7 @@ export class ApRequestService {
|
||||||
private userKeypairService: UserKeypairService,
|
private userKeypairService: UserKeypairService,
|
||||||
private httpRequestService: HttpRequestService,
|
private httpRequestService: HttpRequestService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||||
|
@ -251,7 +255,11 @@ export class ApRequestService {
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
validateContentTypeSetAsActivityPub(res);
|
validateContentTypeSetAsActivityPub(res);
|
||||||
|
const finalUrl = res.url; // redirects may have been involved
|
||||||
|
const activity = await res.json() as IObject;
|
||||||
|
|
||||||
return await res.json();
|
assertActivityMatchesUrls(activity, [url, finalUrl]);
|
||||||
|
|
||||||
|
return activity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
|
import * as Bull from 'bullmq';
|
||||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||||
|
@ -15,11 +16,13 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { isCollectionOrOrderedCollection } from './type.js';
|
import { isCollectionOrOrderedCollection, yumeNormalizeObject } from './type.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
import { ApRendererService } from './ApRendererService.js';
|
import { ApRendererService } from './ApRendererService.js';
|
||||||
import { ApRequestService } from './ApRequestService.js';
|
import { ApRequestService } from './ApRequestService.js';
|
||||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
import type { IObject, ICollection, IOrderedCollection, IUnsanitizedObject } from './type.js';
|
||||||
|
import { toASCII } from 'node:punycode';
|
||||||
|
import { yumeAssertAcceptableURL } from './misc/validator.js';
|
||||||
|
|
||||||
export class Resolver {
|
export class Resolver {
|
||||||
private history: Set<string>;
|
private history: Set<string>;
|
||||||
|
@ -52,11 +55,16 @@ export class Resolver {
|
||||||
return Array.from(this.history);
|
return Array.from(this.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getRecursionLimit(): number {
|
||||||
|
return this.recursionLimit;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||||
const collection = typeof value === 'string'
|
const collection = typeof value === 'string'
|
||||||
? await this.resolve(value)
|
? await this.resolve(value)
|
||||||
: value;
|
: yumeNormalizeObject(value);
|
||||||
|
|
||||||
if (isCollectionOrOrderedCollection(collection)) {
|
if (isCollectionOrOrderedCollection(collection)) {
|
||||||
return collection;
|
return collection;
|
||||||
|
@ -66,7 +74,7 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolve(value: string | IObject): Promise<IObject> {
|
private async resolveNotNormalized(value: string | IObject): Promise<IUnsanitizedObject> {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
@ -83,7 +91,7 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.history.size > this.recursionLimit) {
|
if (this.history.size > this.recursionLimit) {
|
||||||
throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`);
|
throw new Bull.UnrecoverableError(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history.add(value);
|
this.history.add(value);
|
||||||
|
@ -102,8 +110,8 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const object = (this.user
|
const object = (this.user
|
||||||
? await this.apRequestService.signedGet(value, this.user) as IObject
|
? await this.apRequestService.signedGet(value, this.user) as IUnsanitizedObject
|
||||||
: await this.httpRequestService.getActivityJson(value)) as IObject;
|
: await this.httpRequestService.getActivityJson(value)) as IUnsanitizedObject;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Array.isArray(object['@context']) ?
|
Array.isArray(object['@context']) ?
|
||||||
|
@ -113,9 +121,27 @@ export class Resolver {
|
||||||
throw new Error('invalid response');
|
throw new Error('invalid response');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HttpRequestService / ApRequestService have already checked that
|
||||||
|
// `object.id` or `object.url` matches the URL used to fetch the
|
||||||
|
// object after redirects; here we double-check that no redirects
|
||||||
|
// bounced between hosts
|
||||||
|
if (object.id == null) {
|
||||||
|
throw new Error('invalid AP object: missing id');
|
||||||
|
}
|
||||||
|
|
||||||
|
yumeAssertAcceptableURL(object.id);
|
||||||
|
yumeAssertAcceptableURL(value);
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async resolve(value: string | IObject): Promise<IObject> {
|
||||||
|
const object = await this.resolveNotNormalized(value);
|
||||||
|
|
||||||
|
return yumeNormalizeObject(object);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private resolveLocal(url: string): Promise<IObject> {
|
private resolveLocal(url: string): Promise<IObject> {
|
||||||
const parsed = this.apDbResolverService.parseUri(url);
|
const parsed = this.apDbResolverService.parseUri(url);
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: dakkar and sharkey-project and yumechi
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { metricCounter } from '@/server/api/MetricsService.js';
|
||||||
|
import type { IObject } from '../type.js';
|
||||||
|
|
||||||
|
const mFetchBadActivityUrl = metricCounter({
|
||||||
|
name: 'misskey_ap_fetch_bad_activity_url',
|
||||||
|
help: 'Fetches that failed because the activity URL did not match the expected Host',
|
||||||
|
labelNames: ['host_received', 'host_expected'],
|
||||||
|
});
|
||||||
|
|
||||||
|
function getHrefFrom(one: IObject|string): string | undefined {
|
||||||
|
if (typeof(one) === 'string') return one;
|
||||||
|
return one.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
|
||||||
|
const idOk = activity.id !== undefined && urls.includes(activity.id);
|
||||||
|
if (idOk) return;
|
||||||
|
|
||||||
|
const url = activity.url;
|
||||||
|
if (url) {
|
||||||
|
// `activity.url` can be an `ApObject = IObject | string | (IObject
|
||||||
|
// | string)[]`, we have to look inside it
|
||||||
|
const activityUrls = Array.isArray(url) ? url.map(getHrefFrom) : [getHrefFrom(url)];
|
||||||
|
const goodUrl = activityUrls.find(u => u && urls.includes(u));
|
||||||
|
|
||||||
|
if (goodUrl) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = urls.map(u => {
|
||||||
|
try {
|
||||||
|
return new URL(u).host;
|
||||||
|
} catch (e) {
|
||||||
|
return '[invalid]';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortDedup = (arr: string[]) => Array.from(new Set(arr)).sort();
|
||||||
|
|
||||||
|
mFetchBadActivityUrl?.inc({ host_received: sortDedup(hosts).join('|'), host_expected: sortDedup(urls).join('|') });
|
||||||
|
|
||||||
|
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${JSON.stringify(activity?.url)}) match location(${urls})`);
|
||||||
|
}
|
|
@ -4,6 +4,28 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Response } from 'node-fetch';
|
import type { Response } from 'node-fetch';
|
||||||
|
import * as Bull from 'bullmq';
|
||||||
|
import { toASCII } from 'node:punycode';
|
||||||
|
|
||||||
|
export function yumeAssertAcceptableURL(url: string | URL): URL {
|
||||||
|
const urlParsed = url instanceof URL ? url : new URL(url);
|
||||||
|
|
||||||
|
if (urlParsed.search.length + urlParsed.pathname.length > 1024) {
|
||||||
|
throw new Bull.UnrecoverableError('URL is too long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlParsed.protocol !== 'https:') {
|
||||||
|
throw new Bull.UnrecoverableError('URL protocol is not https');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlParsed.port && urlParsed.port !== '443') {
|
||||||
|
throw new Bull.UnrecoverableError('URL port is not 443');
|
||||||
|
}
|
||||||
|
|
||||||
|
urlParsed.hostname = toASCII(urlParsed.hostname);
|
||||||
|
|
||||||
|
return urlParsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateContentTypeSetAsActivityPub(response: Response): void {
|
export function validateContentTypeSetAsActivityPub(response: Response): void {
|
||||||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as Bull from 'bullmq';
|
||||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
@ -36,6 +37,7 @@ import { ApQuestionService } from './ApQuestionService.js';
|
||||||
import { ApImageService } from './ApImageService.js';
|
import { ApImageService } from './ApImageService.js';
|
||||||
import type { Resolver } from '../ApResolverService.js';
|
import type { Resolver } from '../ApResolverService.js';
|
||||||
import type { IObject, IPost } from '../type.js';
|
import type { IObject, IPost } from '../type.js';
|
||||||
|
import { yumeAssertAcceptableURL } from '../misc/validator.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApNoteService {
|
export class ApNoteService {
|
||||||
|
@ -77,7 +79,7 @@ export class ApNoteService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public validateNote(object: IObject, uri: string): Error | null {
|
public validateNote(object: IObject, uri: string, actor?: MiRemoteUser): Error | null {
|
||||||
const expectHost = this.utilityService.extractDbHost(uri);
|
const expectHost = this.utilityService.extractDbHost(uri);
|
||||||
const apType = getApType(object);
|
const apType = getApType(object);
|
||||||
|
|
||||||
|
@ -98,6 +100,14 @@ export class ApNoteService {
|
||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actor) {
|
||||||
|
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||||
|
|
||||||
|
if (attribution !== actor.uri) {
|
||||||
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,14 +125,14 @@ export class ApNoteService {
|
||||||
* Noteを作成します。
|
* Noteを作成します。
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(value);
|
const object = await resolver.resolve(value);
|
||||||
|
|
||||||
const entryUri = getApId(value);
|
const entryUri = getApId(value);
|
||||||
const err = this.validateNote(object, entryUri);
|
const err = this.validateNote(object, entryUri, actor);
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error(err.message, {
|
this.logger.error(err.message, {
|
||||||
resolver: { history: resolver.getHistory() },
|
resolver: { history: resolver.getHistory() },
|
||||||
|
@ -136,14 +146,27 @@ export class ApNoteService {
|
||||||
|
|
||||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
if (note.id && !checkHttps(note.id)) {
|
if (note.id == null) {
|
||||||
|
throw new Error('Refusing to create note without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkHttps(note.id)) {
|
||||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getOneApHrefNullable(note.url);
|
const url = getOneApHrefNullable(note.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (url != null) {
|
||||||
throw new Error('unexpected schema of note url: ' + url);
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of note url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actUrl = yumeAssertAcceptableURL(url);
|
||||||
|
const noteUrl = yumeAssertAcceptableURL(note.id);
|
||||||
|
|
||||||
|
if (noteUrl.host !== actUrl.host) {
|
||||||
|
throw new Bull.UnrecoverableError(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Creating the Note: ${note.id}`);
|
this.logger.info(`Creating the Note: ${note.id}`);
|
||||||
|
@ -156,8 +179,9 @@ export class ApNoteService {
|
||||||
const uri = getOneApId(note.attributedTo);
|
const uri = getOneApId(note.attributedTo);
|
||||||
|
|
||||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (cachedActor && cachedActor.isSuspended) {
|
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||||
|
if (actor && actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,7 +213,8 @@ export class ApNoteService {
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||||
|
|
||||||
// 解決した投稿者が凍結されていたらスキップ
|
// 解決した投稿者が凍結されていたらスキップ
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
|
@ -348,15 +373,11 @@ export class ApNoteService {
|
||||||
if (exist) return exist;
|
if (exist) return exist;
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
if (uri.startsWith(this.config.url)) {
|
|
||||||
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
|
||||||
}
|
|
||||||
|
|
||||||
// リモートサーバーからフェッチしてきて登録
|
// リモートサーバーからフェッチしてきて登録
|
||||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||||
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
||||||
return await this.createNote(createFrom, options.resolver, true);
|
return await this.createNote(createFrom, undefined, options.resolver, true);
|
||||||
} finally {
|
} finally {
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
|
|
|
@ -277,16 +277,13 @@ export class ApPersonService implements OnModuleInit {
|
||||||
* Personを作成します。
|
* Personを作成します。
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
public async createPerson(uri: string, resolver: Resolver): Promise<MiRemoteUser> {
|
||||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||||
|
|
||||||
if (uri.startsWith(this.config.url)) {
|
if (uri.startsWith(this.config.url)) {
|
||||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
|
||||||
|
|
||||||
const object = await resolver.resolve(uri);
|
const object = await resolver.resolve(uri);
|
||||||
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
|
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
|
||||||
|
|
||||||
|
@ -557,7 +554,9 @@ export class ApPersonService implements OnModuleInit {
|
||||||
if (moving) updates.movedAt = new Date();
|
if (moving) updates.movedAt = new Date();
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
await this.usersRepository.update(exist.id, updates);
|
if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
if (person.publicKey) {
|
if (person.publicKey) {
|
||||||
await this.userPublickeysRepository.update({ userId: exist.id }, {
|
await this.userPublickeysRepository.update({ userId: exist.id }, {
|
||||||
|
|
|
@ -5,16 +5,19 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, PollsRepository } from '@/models/_.js';
|
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { IPoll } from '@/models/Poll.js';
|
import type { IPoll } from '@/models/Poll.js';
|
||||||
|
import type { MiRemoteUser } from '@/models/User.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isQuestion } from '../type.js';
|
import { getOneApId, isQuestion } from '../type.js';
|
||||||
import { ApLoggerService } from '../ApLoggerService.js';
|
import { ApLoggerService } from '../ApLoggerService.js';
|
||||||
import { ApResolverService } from '../ApResolverService.js';
|
import { ApResolverService } from '../ApResolverService.js';
|
||||||
import type { Resolver } from '../ApResolverService.js';
|
import type { Resolver } from '../ApResolverService.js';
|
||||||
import type { IObject, IQuestion } from '../type.js';
|
import type { IObject } from '../type.js';
|
||||||
|
import { yumeAssertAcceptableURL } from '../misc/validator.js';
|
||||||
|
import { toASCII } from 'punycode';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApQuestionService {
|
export class ApQuestionService {
|
||||||
|
@ -24,6 +27,9 @@ export class ApQuestionService {
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
|
@ -65,28 +71,41 @@ export class ApQuestionService {
|
||||||
* @returns true if updated
|
* @returns true if updated
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
|
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
|
||||||
const uri = typeof value === 'string' ? value : value.id;
|
const uriIn = typeof value === 'string' ? value : value.id;
|
||||||
if (uri == null) throw new Error('uri is null');
|
if (uriIn == null) throw new Error('uri is null');
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
const uri = yumeAssertAcceptableURL(uriIn);
|
||||||
|
|
||||||
|
if (toASCII(this.config.host) === uri.host) throw new Error('uri points local');
|
||||||
|
|
||||||
//#region このサーバーに既に登録されているか
|
//#region このサーバーに既に登録されているか
|
||||||
const note = await this.notesRepository.findOneBy({ uri });
|
const note = await this.notesRepository.findOneBy({ uri: uriIn });
|
||||||
if (note == null) throw new Error('Question is not registered');
|
if (note == null) throw new Error('Question is not registered');
|
||||||
|
|
||||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||||
if (poll == null) throw new Error('Question is not registered');
|
if (poll == null) throw new Error('Question is not registered');
|
||||||
|
|
||||||
|
const user = await this.usersRepository.findOneBy({ id: poll.userId });
|
||||||
|
if (user == null) throw new Error('Question is not registered');
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// resolve new Question object
|
// resolve new Question object
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
const question = await resolver.resolve(value) as IQuestion;
|
const question = await resolver.resolve(value);
|
||||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||||
|
|
||||||
if (question.type !== 'Question') throw new Error('object is not a Question');
|
if (!isQuestion(question)) throw new Error('object is not a Question');
|
||||||
|
|
||||||
|
const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri;
|
||||||
|
const attributionMatchesExisting = attribution === user.uri;
|
||||||
|
const actorMatchesAttribution = (actor) ? attribution === actor.uri : true;
|
||||||
|
|
||||||
|
if (!attributionMatchesExisting || !actorMatchesAttribution) {
|
||||||
|
throw new Error('Refusing to ingest update for poll by different user');
|
||||||
|
}
|
||||||
|
|
||||||
const apChoices = question.oneOf ?? question.anyOf;
|
const apChoices = question.oneOf ?? question.anyOf;
|
||||||
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
||||||
|
@ -96,7 +115,7 @@ export class ApQuestionService {
|
||||||
for (const choice of poll.choices) {
|
for (const choice of poll.choices) {
|
||||||
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
||||||
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
||||||
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
|
if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new Error('invalid newCount: ' + newCount);
|
||||||
|
|
||||||
if (oldCount !== newCount) {
|
if (oldCount !== newCount) {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
|
@ -3,20 +3,45 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { target } from "happy-dom/lib/PropertySymbol.js";
|
||||||
|
import { toASCII } from "node:punycode";
|
||||||
|
import * as bull from "bullmq";
|
||||||
|
|
||||||
export type Obj = { [x: string]: any };
|
export type Obj = { [x: string]: any };
|
||||||
export type ApObject = IObject | string | (IObject | string)[];
|
export type ApObject = IObject | string | (IObject | string)[];
|
||||||
|
|
||||||
export interface IObject {
|
export interface MisskeyVendorKeys {
|
||||||
|
_misskey_summary: string;
|
||||||
|
_misskey_followedMessage: string | null;
|
||||||
|
_misskey_requireSigninToViewContents: boolean;
|
||||||
|
_misskey_makeNotesFollowersOnlyBefore: number | null;
|
||||||
|
_misskey_makeNotesHiddenBefore: number | null;
|
||||||
|
_misskey_quote: string;
|
||||||
|
_misskey_content: string;
|
||||||
|
_misskey_reaction: string;
|
||||||
|
_misskey_votes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMisskeyVendorKeys(object: IObject): Partial<MisskeyVendorKeys> {
|
||||||
|
return {
|
||||||
|
_misskey_summary: object._misskey_summary,
|
||||||
|
_misskey_followedMessage: object._misskey_followedMessage,
|
||||||
|
_misskey_requireSigninToViewContents: object._misskey_requireSigninToViewContents,
|
||||||
|
_misskey_makeNotesFollowersOnlyBefore: object._misskey_makeNotesFollowersOnlyBefore,
|
||||||
|
_misskey_makeNotesHiddenBefore: object._misskey_makeNotesHiddenBefore,
|
||||||
|
_misskey_quote: object._misskey_quote,
|
||||||
|
_misskey_content: object._misskey_content,
|
||||||
|
_misskey_reaction: object._misskey_reaction,
|
||||||
|
_misskey_votes: object._misskey_votes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUnsanitizedObject extends Partial<MisskeyVendorKeys> {
|
||||||
'@context'?: string | string[] | Obj | Obj[];
|
'@context'?: string | string[] | Obj | Obj[];
|
||||||
type: string | string[];
|
type: string | string[];
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
_misskey_summary?: string;
|
|
||||||
_misskey_followedMessage?: string | null;
|
|
||||||
_misskey_requireSigninToViewContents?: boolean;
|
|
||||||
_misskey_makeNotesFollowersOnlyBefore?: number | null;
|
|
||||||
_misskey_makeNotesHiddenBefore?: number | null;
|
|
||||||
published?: string;
|
published?: string;
|
||||||
cc?: ApObject;
|
cc?: ApObject;
|
||||||
to?: ApObject;
|
to?: ApObject;
|
||||||
|
@ -34,6 +59,80 @@ export interface IObject {
|
||||||
href?: string;
|
href?: string;
|
||||||
tag?: IObject | IObject[];
|
tag?: IObject | IObject[];
|
||||||
sensitive?: boolean;
|
sensitive?: boolean;
|
||||||
|
|
||||||
|
visibility?: string;
|
||||||
|
mentionedUsers?: any[];
|
||||||
|
visibleUsers?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObject extends IUnsanitizedObject {
|
||||||
|
__yume_normalized_object: true | 'outgoing';
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface YumeDowncastSanitizedBadge<L extends 'question' | undefined> {
|
||||||
|
__yume_normalized_badge: L | 'outgoing';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function markOutgoing<T, L extends 'question' | undefined>(object: T, _badge: L): T & IObject & YumeDowncastSanitizedBadge<L> {
|
||||||
|
return object as T & IObject & YumeDowncastSanitizedBadge<L>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeNormalizeURL(url: string): string {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.host = toASCII(u.host);
|
||||||
|
if (u.protocol && u.protocol !== 'https:') {
|
||||||
|
throw new bull.UnrecoverableError('protocol is not https');
|
||||||
|
}
|
||||||
|
u.protocol = 'https:';
|
||||||
|
if (u.port && u.port !== '443') {
|
||||||
|
throw new bull.UnrecoverableError('port is not 443');
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeNormalizeRecursive<O extends IUnsanitizedObject | string | (IUnsanitizedObject | string)[]>(object: O, depth = 0):
|
||||||
|
IObject | string | (IObject | string)[] {
|
||||||
|
if (depth > 16) {
|
||||||
|
throw new bull.UnrecoverableError('recursion limit exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof object === 'string') {
|
||||||
|
return yumeNormalizeURL(object);
|
||||||
|
}
|
||||||
|
if (Array.isArray(object)) {
|
||||||
|
if (object.length > 64) {
|
||||||
|
throw new bull.UnrecoverableError('array length limit exceeded');
|
||||||
|
}
|
||||||
|
return object.flatMap((x) => yumeNormalizeRecursive(x, depth + (object.length + 3 / 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return yumeNormalizeObject(object, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeNormalizeObject(object: IUnsanitizedObject, depth = 0): IObject {
|
||||||
|
if (object.cc) {
|
||||||
|
object.cc = yumeNormalizeRecursive(object.cc, depth + 1);
|
||||||
|
}
|
||||||
|
if (object.id) {
|
||||||
|
object.id = yumeNormalizeURL(object.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.url) {
|
||||||
|
object.url = yumeNormalizeRecursive(object.url, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.replies) {
|
||||||
|
object.replies.first = object.replies.first ?
|
||||||
|
typeof object.replies.first === 'string' ? yumeNormalizeURL(object.replies.first) : yumeNormalizeObject(object.replies.first, depth + 1) : undefined;
|
||||||
|
object.replies.items = object.replies.items ?
|
||||||
|
typeof object.replies.items === 'string' ? yumeNormalizeURL(object.replies.items) : yumeNormalizeRecursive(object.replies.items, depth + 1) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.inReplyTo) {
|
||||||
|
object.inReplyTo = yumeNormalizeRecursive(object.inReplyTo, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return object as IObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -80,7 +179,7 @@ export function getOneApHrefNullable(value: ApObject | undefined): string | unde
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
|
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
if (typeof value?.href === 'string') return value.href;
|
if (typeof value?.href === 'string') return value.href;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -101,6 +200,28 @@ export interface IActivity extends IObject {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SafeList {
|
||||||
|
id: string;
|
||||||
|
content: string | null;
|
||||||
|
tag: IObject | IObject[];
|
||||||
|
published: string;
|
||||||
|
visibility: string;
|
||||||
|
mentionedUsers: any[];
|
||||||
|
visibleUsers: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSafe(object: IObject): Partial<SafeList> {
|
||||||
|
return {
|
||||||
|
id: object.id,
|
||||||
|
content: object.content,
|
||||||
|
tag: object.tag,
|
||||||
|
published: object.published,
|
||||||
|
visibility: object.visibility,
|
||||||
|
mentionedUsers: object.mentionedUsers,
|
||||||
|
visibleUsers: object.visibleUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICollection extends IObject {
|
export interface ICollection extends IObject {
|
||||||
type: 'Collection';
|
type: 'Collection';
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
|
@ -122,7 +243,7 @@ export const isPost = (object: IObject): object is IPost => {
|
||||||
return type != null && validPost.includes(type);
|
return type != null && validPost.includes(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IPost extends IObject {
|
export interface IPost extends IObject{
|
||||||
type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event';
|
type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event';
|
||||||
source?: {
|
source?: {
|
||||||
content: string;
|
content: string;
|
||||||
|
@ -133,7 +254,7 @@ export interface IPost extends IObject {
|
||||||
quoteUrl?: string;
|
quoteUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IQuestion extends IObject {
|
export interface IUnsanitizedQuestion extends IObject {
|
||||||
type: 'Note' | 'Question';
|
type: 'Note' | 'Question';
|
||||||
actor: string;
|
actor: string;
|
||||||
source?: {
|
source?: {
|
||||||
|
@ -148,7 +269,25 @@ export interface IQuestion extends IObject {
|
||||||
closed?: Date;
|
closed?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isQuestion = (object: IObject): object is IQuestion =>
|
export interface IQuestion extends IUnsanitizedQuestion, YumeDowncastSanitizedBadge<'question'> {}
|
||||||
|
|
||||||
|
export function yumeSanitizeQuestion(object: IUnsanitizedQuestion): IQuestion {
|
||||||
|
return {
|
||||||
|
type: object.type,
|
||||||
|
actor: yumeNormalizeURL(object.actor),
|
||||||
|
source: object.source,
|
||||||
|
_misskey_quote: object._misskey_quote,
|
||||||
|
quoteUrl: object.quoteUrl ? yumeNormalizeURL(object.quoteUrl) : '',
|
||||||
|
oneOf: object.oneOf,
|
||||||
|
anyOf: object.anyOf,
|
||||||
|
endTime: object.endTime,
|
||||||
|
closed: object.closed,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
__yume_normalized_badge: 'question',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isQuestion = (object: IObject): object is IUnsanitizedQuestion =>
|
||||||
getApType(object) === 'Note' || getApType(object) === 'Question';
|
getApType(object) === 'Note' || getApType(object) === 'Question';
|
||||||
|
|
||||||
interface IQuestionChoice {
|
interface IQuestionChoice {
|
||||||
|
@ -264,88 +403,307 @@ export const isDocument = (object: IObject): object is IApDocument => {
|
||||||
return type != null && validDocumentTypes.includes(type);
|
return type != null && validDocumentTypes.includes(type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IApImage extends IApDocument {
|
export interface IApImage extends IApDocument, Partial<SafeList> {
|
||||||
type: 'Image';
|
type: 'Image';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICreate extends IActivity {
|
export interface ICreate extends IActivity, Partial<SafeList> {
|
||||||
type: 'Create';
|
type: 'Create';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDelete extends IActivity {
|
export interface IDelete extends IActivity, Partial<SafeList> {
|
||||||
type: 'Delete';
|
type: 'Delete';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUpdate extends IActivity {
|
export interface IUpdate extends IActivity, Partial<SafeList> {
|
||||||
type: 'Update';
|
type: 'Update';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRead extends IActivity {
|
export interface IRead extends IActivity, Partial<SafeList> {
|
||||||
type: 'Read';
|
type: 'Read';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUndo extends IActivity {
|
export interface IUndo extends IActivity, Partial<SafeList> {
|
||||||
type: 'Undo';
|
type: 'Undo';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFollow extends IActivity {
|
export interface IFollow extends IActivity, Partial<SafeList> {
|
||||||
type: 'Follow';
|
type: 'Follow';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAccept extends IActivity {
|
export interface IAccept extends IActivity, Partial<SafeList> {
|
||||||
type: 'Accept';
|
type: 'Accept';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReject extends IActivity {
|
export interface IReject extends IActivity, Partial<SafeList> {
|
||||||
type: 'Reject';
|
type: 'Reject';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAdd extends IActivity {
|
export interface IAdd extends IActivity, Partial<SafeList> {
|
||||||
type: 'Add';
|
type: 'Add';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRemove extends IActivity {
|
export interface IRemove extends IActivity, Partial<SafeList> {
|
||||||
type: 'Remove';
|
type: 'Remove';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILike extends IActivity {
|
export interface ILike extends IActivity, Partial<SafeList> {
|
||||||
type: 'Like' | 'EmojiReaction' | 'EmojiReact';
|
type: 'Like' | 'EmojiReaction' | 'EmojiReact';
|
||||||
_misskey_reaction?: string;
|
_misskey_reaction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAnnounce extends IActivity {
|
export interface IAnnounce extends IActivity, Partial<SafeList> {
|
||||||
type: 'Announce';
|
type: 'Announce';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBlock extends IActivity {
|
export interface IBlock extends IActivity, Partial<SafeList> {
|
||||||
type: 'Block';
|
type: 'Block';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFlag extends IActivity {
|
export interface IFlag extends IActivity, Partial<SafeList> {
|
||||||
type: 'Flag';
|
type: 'Flag';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMove extends IActivity {
|
export interface IMove extends IActivity, Partial<SafeList> {
|
||||||
type: 'Move';
|
type: 'Move';
|
||||||
target: IObject | string;
|
target: IObject | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
|
export function yumeDowncastCreate(object: IObject): ICreate | null {
|
||||||
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
|
if (getApType(object) !== 'Create') return null;
|
||||||
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
|
const obj = object as ICreate;
|
||||||
export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read';
|
if (!obj.actor || !obj.object) return null;
|
||||||
export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo';
|
return {
|
||||||
export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow';
|
...extractMisskeyVendorKeys(object),
|
||||||
export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept';
|
...extractSafe(object),
|
||||||
export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject';
|
type: 'Create',
|
||||||
export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add';
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove';
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
export const isLike = (object: IObject): object is ILike => {
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
const type = getApType(object);
|
__yume_normalized_object: true,
|
||||||
return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type);
|
};
|
||||||
};
|
}
|
||||||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
|
|
||||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
export function yumeDowncastDelete(object: IObject): IDelete | null {
|
||||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
if (getApType(object) !== 'Delete') return null;
|
||||||
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';
|
const obj = object as IDelete;
|
||||||
export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note';
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Delete',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastUpdate(object: IObject): IUpdate | null {
|
||||||
|
if (getApType(object) !== 'Update') return null;
|
||||||
|
const obj = object as IUpdate;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Update',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastRead(object: IObject): IRead | null {
|
||||||
|
if (getApType(object) !== 'Read') return null;
|
||||||
|
const obj = object as IRead;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Read',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastUndo(object: IObject): IUndo | null {
|
||||||
|
if (getApType(object) !== 'Undo') return null;
|
||||||
|
const obj = object as IUndo;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Undo',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastFollow(object: IObject): IFollow | null {
|
||||||
|
if (getApType(object) !== 'Follow') return null;
|
||||||
|
const obj = object as IFollow;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Follow',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastAccept(object: IObject): IAccept | null {
|
||||||
|
if (getApType(object) !== 'Accept') return null;
|
||||||
|
const obj = object as IAccept;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Accept',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastReject(object: IObject): IReject | null {
|
||||||
|
if (getApType(object) !== 'Reject') return null;
|
||||||
|
const obj = object as IReject;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Reject',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastAdd(object: IObject): IAdd | null {
|
||||||
|
if (getApType(object) !== 'Add') return null;
|
||||||
|
const obj = object as IAdd;
|
||||||
|
if (!obj.actor || !obj.object ) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Add',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastRemove(object: IObject): IRemove | null {
|
||||||
|
if (getApType(object) !== 'Remove') return null;
|
||||||
|
const obj = object as IRemove;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Remove',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastLike(object: IObject): ILike | null {
|
||||||
|
if (getApType(object) !== 'Like') return null;
|
||||||
|
const obj = object as ILike;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Like',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastAnnounce(object: IObject): IAnnounce | null {
|
||||||
|
if (getApType(object) !== 'Announce') return null;
|
||||||
|
const obj = object as IAnnounce;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
// ...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Announce',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastBlock(object: IObject): IBlock | null {
|
||||||
|
if (getApType(object) !== 'Block') return null;
|
||||||
|
const obj = object as IBlock;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Block',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastFlag(object: IObject): IFlag | null {
|
||||||
|
if (getApType(object) !== 'Flag') return null;
|
||||||
|
const obj = object as IFlag;
|
||||||
|
if (!obj.actor || !obj.object) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Flag',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yumeDowncastMove(object: IObject): IMove | null {
|
||||||
|
if (getApType(object) !== 'Move') return null;
|
||||||
|
const obj = object as IMove;
|
||||||
|
if (!obj.actor || !obj.object || !obj.target) return null;
|
||||||
|
return {
|
||||||
|
...extractMisskeyVendorKeys(object),
|
||||||
|
...extractSafe(object),
|
||||||
|
type: 'Move',
|
||||||
|
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
|
||||||
|
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
|
||||||
|
target: typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target),
|
||||||
|
__yume_normalized_object: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function yumeDowncastMention(object: IObject): IApMention | null {
|
||||||
|
if (getApType(object) !== 'Mention') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = getApHrefNullable(object);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...object,
|
||||||
|
type: 'Mention',
|
||||||
|
href: href ? yumeNormalizeURL(href) : '',
|
||||||
|
name: object.name ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -18,7 +18,6 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { UtilityService } from '../UtilityService.js';
|
import { UtilityService } from '../UtilityService.js';
|
||||||
import { VideoProcessingService } from '../VideoProcessingService.js';
|
|
||||||
import { UserEntityService } from './UserEntityService.js';
|
import { UserEntityService } from './UserEntityService.js';
|
||||||
import { DriveFolderEntityService } from './DriveFolderEntityService.js';
|
import { DriveFolderEntityService } from './DriveFolderEntityService.js';
|
||||||
|
|
||||||
|
@ -43,7 +42,6 @@ export class DriveFileEntityService {
|
||||||
|
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private driveFolderEntityService: DriveFolderEntityService,
|
private driveFolderEntityService: DriveFolderEntityService,
|
||||||
private videoProcessingService: VideoProcessingService,
|
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
@ -86,11 +84,7 @@ export class DriveFileEntityService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public getThumbnailUrl(file: MiDriveFile): string | null {
|
public getThumbnailUrl(file: MiDriveFile): string | null {
|
||||||
if (file.type.startsWith('video')) {
|
if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
|
||||||
if (file.thumbnailUrl) return file.thumbnailUrl;
|
|
||||||
|
|
||||||
return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url);
|
|
||||||
} else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
|
|
||||||
// 動画ではなくリモートかつメディアプロキシ
|
// 動画ではなくリモートかつメディアプロキシ
|
||||||
return this.getProxiedUrl(file.uri, 'static');
|
return this.getProxiedUrl(file.uri, 'static');
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,8 @@ export class DriveFolderEntityService {
|
||||||
public async pack(
|
public async pack(
|
||||||
src: MiDriveFolder['id'] | MiDriveFolder,
|
src: MiDriveFolder['id'] | MiDriveFolder,
|
||||||
options?: {
|
options?: {
|
||||||
detail: boolean
|
detail: boolean,
|
||||||
|
maxDepth?: number,
|
||||||
},
|
},
|
||||||
): Promise<Packed<'DriveFolder'>> {
|
): Promise<Packed<'DriveFolder'>> {
|
||||||
const opts = Object.assign({
|
const opts = Object.assign({
|
||||||
|
@ -55,7 +56,8 @@ export class DriveFolderEntityService {
|
||||||
|
|
||||||
...(folder.parentId ? {
|
...(folder.parentId ? {
|
||||||
parent: this.pack(folder.parentId, {
|
parent: this.pack(folder.parentId, {
|
||||||
detail: true,
|
detail: (options?.maxDepth ? options.maxDepth > 0 : true),
|
||||||
|
maxDepth: options?.maxDepth || 32,
|
||||||
}),
|
}),
|
||||||
} : {}),
|
} : {}),
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|
|
@ -10,46 +10,85 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { } from '@/models/Blocking.js';
|
import type { } from '@/models/Blocking.js';
|
||||||
import type { MiEmoji } from '@/models/Emoji.js';
|
import type { MiEmoji } from '@/models/Emoji.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { In } from 'typeorm';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmojiEntityService {
|
export class EmojiEntityService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.emojisRepository)
|
@Inject(DI.emojisRepository)
|
||||||
private emojisRepository: EmojisRepository,
|
private emojisRepository: EmojisRepository,
|
||||||
|
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stripProxyIfOrigin(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
let origin = u.origin;
|
||||||
|
if (u.origin === new URL(this.config.mediaProxy).origin) {
|
||||||
|
const innerUrl = u.searchParams.get('url');
|
||||||
|
if (innerUrl) {
|
||||||
|
origin = new URL(innerUrl).origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (origin === u.origin) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public packSimpleNoQuery(
|
||||||
|
emoji: MiEmoji,
|
||||||
|
): Packed<'EmojiSimple'> {
|
||||||
|
return {
|
||||||
|
aliases: emoji.aliases,
|
||||||
|
name: emoji.name,
|
||||||
|
category: emoji.category,
|
||||||
|
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||||
|
url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl),
|
||||||
|
localOnly: emoji.localOnly ? true : undefined,
|
||||||
|
isSensitive: emoji.isSensitive ? true : undefined,
|
||||||
|
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async packSimple(
|
public async packSimple(
|
||||||
src: MiEmoji['id'] | MiEmoji,
|
src: MiEmoji['id'] | MiEmoji,
|
||||||
): Promise<Packed<'EmojiSimple'>> {
|
): Promise<Packed<'EmojiSimple'>> {
|
||||||
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
return {
|
return this.packSimpleNoQuery(emoji);
|
||||||
aliases: emoji.aliases,
|
|
||||||
name: emoji.name,
|
|
||||||
category: emoji.category,
|
|
||||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
|
||||||
url: emoji.publicUrl || emoji.originalUrl,
|
|
||||||
localOnly: emoji.localOnly ? true : undefined,
|
|
||||||
isSensitive: emoji.isSensitive ? true : undefined,
|
|
||||||
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public packSimpleMany(
|
public async packSimpleMany(
|
||||||
emojis: any[],
|
emojis: MiEmoji['id'][] | MiEmoji[],
|
||||||
) {
|
): Promise<Packed<'EmojiSimple'>[]> {
|
||||||
return Promise.all(emojis.map(x => this.packSimple(x)));
|
if (emojis.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof emojis[0] === 'string') {
|
||||||
|
const res = await this.emojisRepository.findBy({ id: In(emojis as MiEmoji['id'][]) });
|
||||||
|
return res.map(this.packSimpleNoQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (emojis as MiEmoji[]).map(this.packSimpleNoQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async packDetailed(
|
public packDetailedNoQuery(
|
||||||
src: MiEmoji['id'] | MiEmoji,
|
emoji: MiEmoji,
|
||||||
): Promise<Packed<'EmojiDetailed'>> {
|
): Packed<'EmojiDetailed'> {
|
||||||
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: emoji.id,
|
id: emoji.id,
|
||||||
aliases: emoji.aliases,
|
aliases: emoji.aliases,
|
||||||
|
@ -57,7 +96,7 @@ export class EmojiEntityService {
|
||||||
category: emoji.category,
|
category: emoji.category,
|
||||||
host: emoji.host,
|
host: emoji.host,
|
||||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||||
url: emoji.publicUrl || emoji.originalUrl,
|
url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl),
|
||||||
license: emoji.license,
|
license: emoji.license,
|
||||||
isSensitive: emoji.isSensitive,
|
isSensitive: emoji.isSensitive,
|
||||||
localOnly: emoji.localOnly,
|
localOnly: emoji.localOnly,
|
||||||
|
@ -66,10 +105,28 @@ export class EmojiEntityService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public packDetailedMany(
|
public async packDetailed(
|
||||||
emojis: any[],
|
src: MiEmoji['id'] | MiEmoji,
|
||||||
) {
|
): Promise<Packed<'EmojiDetailed'>> {
|
||||||
return Promise.all(emojis.map(x => this.packDetailed(x)));
|
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
return this.packDetailedNoQuery(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async packDetailedMany(
|
||||||
|
emojis: MiEmoji['id'][] | MiEmoji[],
|
||||||
|
) : Promise<Packed<'EmojiDetailed'>[]> {
|
||||||
|
if (emojis.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof emojis[0] === 'string') {
|
||||||
|
const res = await this.emojisRepository.findBy({ id: In(emojis as MiEmoji['id'][]) });
|
||||||
|
return res.map(this.packDetailedNoQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (emojis as MiEmoji[]).map(this.packDetailedNoQuery);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
72
packages/backend/src/misc/log-sanitization.ts
Normal file
72
packages/backend/src/misc/log-sanitization.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project and yumechi
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { aidRegExp } from "./id/aid.js";
|
||||||
|
import { aidxRegExp } from "./id/aidx.js";
|
||||||
|
|
||||||
|
export function sanitizeRequestURI(uri: string): string {
|
||||||
|
const vite = /^\/vite\/.+\.([a-z0-9]{1,4})$/;
|
||||||
|
const embed_vite = /^\/embed_vite\/.+\.([a-z0-9]{1,4})$/;
|
||||||
|
|
||||||
|
if (vite.test(uri)) {
|
||||||
|
return '[vite]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embed_vite.test(uri)) {
|
||||||
|
return '[embed_vite]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/emoji/')) {
|
||||||
|
return '/emoji/[emoji]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/identicon/')) {
|
||||||
|
return '/identicon/[identicon]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/tags/')) {
|
||||||
|
return '/tags/[tag]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/user-tags/')) {
|
||||||
|
return '/user-tags/[tag]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/page/')) {
|
||||||
|
return '/page/[page]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/fluent-emoji/')) {
|
||||||
|
return '/fluent-emoji/[fluent-emoji]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/twemoji/')) {
|
||||||
|
return '/twemoji/[twemoji]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.startsWith('/twemoji-badge/')) {
|
||||||
|
return '/twemoji-badge/[twemoji-badge]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uri.startsWith('/api/')) {
|
||||||
|
return '[other]';
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
||||||
|
const username_local = /\/@\w+(\/|$)/;
|
||||||
|
const username_remote = /\/@\w+@[a-zA-Z0-9-.]+\.[a-zA-Z]{2,4}(\/|$)/;
|
||||||
|
const token = /=[0-9a-zA-Z]{16}/g;
|
||||||
|
const aidx = new RegExp(`/${aidxRegExp.source.replace(/^\^/, '').replace(/\$$/, '')}(\/|$)`, 'g');
|
||||||
|
const aid = new RegExp(`/${aidRegExp.source.replace(/^\^/, '').replace(/\$$/, '')}(\/|$)`, 'g');
|
||||||
|
|
||||||
|
return uri
|
||||||
|
.replace(aidx, '/[aidx]/')
|
||||||
|
.replace(aid, '/[aid]/')
|
||||||
|
.replace(token, '=[token]')
|
||||||
|
.replace(uuid, '[uuid]')
|
||||||
|
.replace(username_local, '/[user_local]/')
|
||||||
|
.replace(username_remote, '/[user_remote]/');
|
||||||
|
}
|
||||||
|
|
|
@ -61,7 +61,7 @@ export class MiDriveFile {
|
||||||
public size: number;
|
public size: number;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 512, nullable: true,
|
length: 32768, nullable: true,
|
||||||
comment: 'The comment of the DriveFile.',
|
comment: 'The comment of the DriveFile.',
|
||||||
})
|
})
|
||||||
public comment: string | null;
|
public comment: string | null;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, ViewEntity } from 'typeorm';
|
||||||
import { id } from './util/id.js';
|
import { id } from './util/id.js';
|
||||||
import { MiUser } from './User.js';
|
import { MiUser } from './User.js';
|
||||||
|
|
||||||
|
@ -98,3 +98,4 @@ export class MiFollowing {
|
||||||
public followeeSharedInbox: string | null;
|
public followeeSharedInbox: string | null;
|
||||||
//#endregion
|
//#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,7 @@ export class MiNote {
|
||||||
public threadId: string | null;
|
public threadId: string | null;
|
||||||
|
|
||||||
// TODO: varcharにしたい
|
// TODO: varcharにしたい
|
||||||
|
@Index() // USING pgroonga
|
||||||
@Column('text', {
|
@Column('text', {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
|
@ -60,6 +61,7 @@ export class MiNote {
|
||||||
})
|
})
|
||||||
public name: string | null;
|
public name: string | null;
|
||||||
|
|
||||||
|
@Index() // USING pgroonga pgroonga_varchar_full_text_search_ops_v2
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 512, nullable: true,
|
length: 512, nullable: true,
|
||||||
})
|
})
|
||||||
|
|
|
@ -49,6 +49,7 @@ export class MiUser {
|
||||||
})
|
})
|
||||||
public usernameLower: string;
|
public usernameLower: string;
|
||||||
|
|
||||||
|
@Index() // USING pgroonga pgroonga_varchar_full_text_search_ops_v2
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 128, nullable: true,
|
length: 128, nullable: true,
|
||||||
comment: 'The name of the User.',
|
comment: 'The name of the User.',
|
||||||
|
|
|
@ -36,6 +36,7 @@ export class MiUserProfile {
|
||||||
})
|
})
|
||||||
public birthday: string | null;
|
public birthday: string | null;
|
||||||
|
|
||||||
|
@Index() // USING pgroonga pgroonga_varchar_full_text_search_ops_v2
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 2048, nullable: true,
|
length: 2048, nullable: true,
|
||||||
comment: 'The description (bio) of the User.',
|
comment: 'The description (bio) of the User.',
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { DataSource, Logger } from 'typeorm';
|
import { DataSource, Logger } from 'typeorm';
|
||||||
import * as highlight from 'cli-highlight';
|
import * as highlight from 'cli-highlight';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { entities as charts } from '@/core/chart/entities.js';
|
import { entities as charts } from '@/core/chart/entities.js';
|
||||||
|
|
||||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||||
|
@ -82,6 +83,8 @@ import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||||
import { Config } from '@/config.js';
|
import { Config } from '@/config.js';
|
||||||
import MisskeyLogger from '@/logger.js';
|
import MisskeyLogger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { MemoryKVCache } from './misc/cache.js';
|
||||||
|
import { metricCounter, metricHistogram } from './server/api/MetricsService.js';
|
||||||
|
|
||||||
pg.types.setTypeParser(20, Number);
|
pg.types.setTypeParser(20, Number);
|
||||||
|
|
||||||
|
@ -89,7 +92,56 @@ export const dbLogger = new MisskeyLogger('db');
|
||||||
|
|
||||||
const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
|
const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
|
||||||
|
|
||||||
|
type QueryTagCache = {
|
||||||
|
join: string;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function dedupConsecutive<T>(arr: T[]): T[] {
|
||||||
|
return arr.filter((v, i, a) => i === 0 || a[i - 1] !== v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyIdentifiers(sql: string) {
|
||||||
|
return sql.replace(/"([a-zA-Z_]+)"/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQueryTags(query: string): QueryTagCache {
|
||||||
|
const joins = query.matchAll(/(LEFT|RIGHT|INNER|OUTER)[\s\S]+JOIN[\s\r\n]+([a-zA-Z0-9_"`.]+)/ig);
|
||||||
|
const froms = query.matchAll(/FROM[\s\r\n]+([a-zA-Z0-9_"`.]+)/ig);
|
||||||
|
|
||||||
|
const join = Array.from(joins).map(j => `${j[1]}:${simplifyIdentifiers(j[2])}`).join('|');
|
||||||
|
const from = dedupConsecutive(Array.from(froms).map(f => simplifyIdentifiers(f[1]))).join('|');
|
||||||
|
|
||||||
|
return {
|
||||||
|
join,
|
||||||
|
from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mQueryCounter = metricCounter({
|
||||||
|
name: 'misskey_postgres_query_total',
|
||||||
|
help: 'Total queries to postgres',
|
||||||
|
labelNames: ['join', 'from'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mQueryErrorCounter = metricCounter({
|
||||||
|
name: 'misskey_postgres_query_error_total',
|
||||||
|
help: 'Total errors in queries to postgres',
|
||||||
|
labelNames: ['join', 'from'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mSlowQueryHisto = metricHistogram({
|
||||||
|
name: 'misskey_postgres_query_slow_duration_seconds',
|
||||||
|
help: 'Duration of slow queries to postgres',
|
||||||
|
labelNames: ['join', 'from'],
|
||||||
|
buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 300],
|
||||||
|
});
|
||||||
|
|
||||||
class MyCustomLogger implements Logger {
|
class MyCustomLogger implements Logger {
|
||||||
|
constructor(private metricOnly = true) {}
|
||||||
|
|
||||||
|
private queryHashCache = new MemoryKVCache<QueryTagCache>(1000 * 60 * 5); // 5m
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private highlight(sql: string) {
|
private highlight(sql: string) {
|
||||||
return highlight.highlight(sql, {
|
return highlight.highlight(sql, {
|
||||||
|
@ -97,33 +149,69 @@ class MyCustomLogger implements Logger {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private getQueryTags(query: string): QueryTagCache {
|
||||||
|
const existing = this.queryHashCache.get(query);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = extractQueryTags(query);
|
||||||
|
this.queryHashCache.set(query, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logQuery(query: string, parameters?: any[]) {
|
public logQuery(query: string, parameters?: any[]) {
|
||||||
|
mQueryCounter?.inc(this.getQueryTags(query));
|
||||||
|
|
||||||
|
if (this.metricOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sqlLogger.info(this.highlight(query).substring(0, 100));
|
sqlLogger.info(this.highlight(query).substring(0, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logQueryError(error: string, query: string, parameters?: any[]) {
|
public logQueryError(error: string, query: string, parameters?: any[]) {
|
||||||
|
mQueryErrorCounter?.inc(this.getQueryTags(query));
|
||||||
|
|
||||||
|
if (this.metricOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sqlLogger.error(this.highlight(query));
|
sqlLogger.error(this.highlight(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logQuerySlow(time: number, query: string, parameters?: any[]) {
|
public logQuerySlow(time: number, query: string, parameters?: any[]) {
|
||||||
|
mSlowQueryHisto?.observe(this.getQueryTags(query), time);
|
||||||
|
|
||||||
|
if (this.metricOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sqlLogger.warn(this.highlight(query));
|
sqlLogger.warn(this.highlight(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logSchemaBuild(message: string) {
|
public logSchemaBuild(message: string) {
|
||||||
|
if (this.metricOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sqlLogger.info(message);
|
sqlLogger.info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public log(message: string) {
|
public log(message: string) {
|
||||||
|
if (this.metricOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sqlLogger.info(message);
|
sqlLogger.info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public logMigration(message: string) {
|
public logMigration(message: string) {
|
||||||
|
if (this.metricOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
sqlLogger.info(message);
|
sqlLogger.info(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -203,7 +291,7 @@ export const entities = [
|
||||||
|
|
||||||
const log = process.env.NODE_ENV !== 'production';
|
const log = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
export function createPostgresDataSource(config: Config) {
|
export function createPostgresDataSource(config: Config, isMain = false) {
|
||||||
return new DataSource({
|
return new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: config.db.host,
|
host: config.db.host,
|
||||||
|
@ -246,9 +334,9 @@ export function createPostgresDataSource(config: Config) {
|
||||||
db: config.redis.db ?? 0,
|
db: config.redis.db ?? 0,
|
||||||
},
|
},
|
||||||
} : false,
|
} : false,
|
||||||
logging: log,
|
logging: true,
|
||||||
logger: log ? new MyCustomLogger() : undefined,
|
logger: new MyCustomLogger(!log),
|
||||||
maxQueryExecutionTime: 300,
|
maxQueryExecutionTime: 500,
|
||||||
entities: entities,
|
entities: entities,
|
||||||
migrations: ['../../migration/*.js'],
|
migrations: ['../../migration/*.js'],
|
||||||
});
|
});
|
||||||
|
|
|
@ -45,6 +45,7 @@ import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||||
import { QUEUE, baseQueueOptions } from './const.js';
|
import { QUEUE, baseQueueOptions } from './const.js';
|
||||||
|
import { mStalledWorkerCounter } from './metrics.js';
|
||||||
|
|
||||||
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
|
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
|
||||||
function httpRelatedBackoff(attemptsMade: number) {
|
function httpRelatedBackoff(attemptsMade: number) {
|
||||||
|
@ -194,7 +195,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.SYSTEM });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -251,7 +255,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.DB });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -291,7 +298,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.DELIVER });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -331,7 +341,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.INBOX });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -371,7 +384,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.USER_WEBHOOK_DELIVER });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -411,7 +427,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.SYSTEM_WEBHOOK_DELIVER });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -458,7 +477,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.RELATIONSHIP });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
@ -499,7 +521,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
.on('stalled', (jobId) => {
|
||||||
|
mStalledWorkerCounter?.inc({ queue: QUEUE.OBJECT_STORAGE });
|
||||||
|
logger.warn(`stalled id=${jobId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
37
packages/backend/src/queue/metrics.ts
Normal file
37
packages/backend/src/queue/metrics.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { metricCounter, metricGauge } from '@/server/api/MetricsService.js';
|
||||||
|
|
||||||
|
export const mJobBlockedCounter = metricCounter({
|
||||||
|
name: 'misskey_queue_jobs_blocked_total',
|
||||||
|
help: 'Total number of jobs waiting for a worker',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mActiveJobs = metricGauge({
|
||||||
|
name: 'misskey_queue_active_jobs',
|
||||||
|
help: 'Number of active jobs in queue',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mDelayedJobs = metricGauge({
|
||||||
|
name: 'misskey_queue_delayed_jobs',
|
||||||
|
help: 'Number of delayed jobs in queue',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mWaitingJobs = metricGauge({
|
||||||
|
name: 'misskey_queue_waiting_jobs',
|
||||||
|
help: 'Number of waiting jobs in queue',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mFailedJobs = metricGauge({
|
||||||
|
name: 'misskey_queue_failed_jobs',
|
||||||
|
help: 'Total number of failed jobs',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mStalledWorkerCounter = metricCounter({
|
||||||
|
name: 'misskey_queue_stalled_workers_total',
|
||||||
|
help: 'Total number of stalled workers',
|
||||||
|
labelNames: ['queue'],
|
||||||
|
});
|
|
@ -4,9 +4,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { MoreThan } from 'typeorm';
|
import { DataSource, MoreThan, QueryFailedError, TypeORMError } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
import { MiUser, type DriveFilesRepository, type NotesRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { DriveService } from '@/core/DriveService.js';
|
import { DriveService } from '@/core/DriveService.js';
|
||||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||||
|
@ -26,6 +26,9 @@ export class DeleteAccountProcessorService {
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@Inject(DI.db)
|
||||||
|
private db: DataSource,
|
||||||
|
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
@ -52,6 +55,14 @@ export class DeleteAccountProcessorService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.isDeleted) {
|
||||||
|
this.logger.warn('User is not pre-marked as deleted, this is likely a bug');
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
throw new Error('User is not pre-marked as deleted'); // make some noise to make sure tests fail
|
||||||
|
}
|
||||||
|
await this.usersRepository.update({ id: user.id }, { isDeleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
{ // Delete notes
|
{ // Delete notes
|
||||||
let cursor: MiNote['id'] | null = null;
|
let cursor: MiNote['id'] | null = null;
|
||||||
|
|
||||||
|
@ -121,13 +132,46 @@ export class DeleteAccountProcessorService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// soft指定されている場合は物理削除しない
|
// Deadlockが発生した場合にリトライする
|
||||||
if (job.data.soft) {
|
for (let remaining = 3; remaining > 0; remaining--) {
|
||||||
// nop
|
try {
|
||||||
} else {
|
// soft指定されている場合は物理削除しない
|
||||||
await this.usersRepository.delete(job.data.user.id);
|
await this.db.transaction(async txn => {
|
||||||
|
// soft指定してもデータをすべで削除する
|
||||||
|
await txn.delete(MiUser, user.id);
|
||||||
|
if (job.data.soft) {
|
||||||
|
await txn.insert(MiUser, {
|
||||||
|
...user,
|
||||||
|
isRoot: false,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
emojis: [],
|
||||||
|
hideOnlineStatus: true,
|
||||||
|
followersCount: 0,
|
||||||
|
followingCount: 0,
|
||||||
|
avatarUrl: null,
|
||||||
|
avatarId: null,
|
||||||
|
notesCount: 0,
|
||||||
|
inbox: null,
|
||||||
|
sharedInbox: null,
|
||||||
|
featured: null,
|
||||||
|
uri: null,
|
||||||
|
followersUri: null,
|
||||||
|
token: null,
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return 'Account deleted';
|
||||||
|
} catch (e) {
|
||||||
|
// 40P01 = deadlock_detected
|
||||||
|
// https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||||
|
if (remaining > 0 && e instanceof QueryFailedError && e.driverError.code === '40P01') {
|
||||||
|
this.logger.warn(`Deadlock occurred, retrying after 1s... [${remaining - 1} remaining]`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Account deleted';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { URL } from 'node:url';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import httpSignature from '@peertube/http-signature';
|
import httpSignature from '@peertube/http-signature';
|
||||||
import * as Bull from 'bullmq';
|
import * as Bull from 'bullmq';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||||
|
@ -29,6 +30,7 @@ import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||||
import { MiNote } from '@/models/Note.js';
|
import { MiNote } from '@/models/Note.js';
|
||||||
import { MiMeta } from '@/models/Meta.js';
|
import { MiMeta } from '@/models/Meta.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { metricCounter, metricHistogram } from '@/server/api/MetricsService.js';
|
||||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||||
import type { InboxJobData } from '../types.js';
|
import type { InboxJobData } from '../types.js';
|
||||||
|
|
||||||
|
@ -37,6 +39,37 @@ type UpdateInstanceJob = {
|
||||||
shouldUnsuspend: boolean,
|
shouldUnsuspend: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mIncomingApProcessingTime = metricHistogram({
|
||||||
|
name: 'misskey_incoming_ap_processing_time',
|
||||||
|
help: 'Incoming AP processing time in seconds',
|
||||||
|
labelNames: ['incoming_host', 'incoming_type', 'success'],
|
||||||
|
buckets: [1, 10, 60, 300, 1800],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mIncomingApEvent = metricCounter({
|
||||||
|
name: 'misskey_incoming_ap_event',
|
||||||
|
help: 'Incoming AP event',
|
||||||
|
labelNames: ['incoming_host', 'incoming_type'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mIncomingApEventAccepted = metricCounter({
|
||||||
|
name: 'misskey_incoming_ap_event_accepted',
|
||||||
|
help: 'Incoming AP event accepted',
|
||||||
|
labelNames: ['incoming_host', 'incoming_type'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mIncomingApReject = metricCounter({
|
||||||
|
name: 'misskey_incoming_ap_reject',
|
||||||
|
help: 'Incoming AP reject',
|
||||||
|
labelNames: ['incoming_host', 'incoming_type', 'reason'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mincomingApProcessingError = metricCounter({
|
||||||
|
name: 'misskey_incoming_ap_processing_error',
|
||||||
|
help: 'Incoming AP processing error',
|
||||||
|
labelNames: ['incoming_host', 'incoming_type'],
|
||||||
|
});
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InboxProcessorService implements OnApplicationShutdown {
|
export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
@ -66,7 +99,6 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
public async process(job: Bull.Job<InboxJobData>): Promise<string> {
|
public async process(job: Bull.Job<InboxJobData>): Promise<string> {
|
||||||
const signature = job.data.signature; // HTTP-signature
|
const signature = job.data.signature; // HTTP-signature
|
||||||
let activity = job.data.activity;
|
let activity = job.data.activity;
|
||||||
|
|
||||||
//#region Log
|
//#region Log
|
||||||
const info = Object.assign({}, activity);
|
const info = Object.assign({}, activity);
|
||||||
delete info['@context'];
|
delete info['@context'];
|
||||||
|
@ -75,12 +107,34 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
|
|
||||||
const host = this.utilityService.toPuny(new URL(signature.keyId).hostname);
|
const host = this.utilityService.toPuny(new URL(signature.keyId).hostname);
|
||||||
|
|
||||||
|
const incCounter = <T extends 'incoming_host' | 'incoming_type', U>(counter: prom.Counter<T> | null, addn_labels: U) => {
|
||||||
|
if (Array.isArray(activity.type)) {
|
||||||
|
for (const t of activity.type) {
|
||||||
|
counter?.inc({ incoming_host: host.toString(), incoming_type: t, ...addn_labels });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
counter?.inc({ incoming_host: host.toString(), incoming_type: activity.type ?? 'unknown', ...addn_labels });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeHistogram = <T extends 'incoming_host' | 'incoming_type', U>(histogram: prom.Histogram<T> | null, addn_labels: U, value: number) => {
|
||||||
|
if (Array.isArray(activity.type)) {
|
||||||
|
for (const t of activity.type) {
|
||||||
|
histogram?.observe({ incoming_host: host.toString(), incoming_type: t, ...addn_labels }, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
histogram?.observe({ incoming_host: host.toString(), incoming_type: activity.type ?? 'unknown', ...addn_labels }, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!this.utilityService.isFederationAllowedHost(host)) {
|
if (!this.utilityService.isFederationAllowedHost(host)) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'host_not_allowed' });
|
||||||
return `Blocked request: ${host}`;
|
return `Blocked request: ${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyIdLower = signature.keyId.toLowerCase();
|
const keyIdLower = signature.keyId.toLowerCase();
|
||||||
if (keyIdLower.startsWith('acct:')) {
|
if (keyIdLower.startsWith('acct:')) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'keyid_acct' });
|
||||||
return `Old keyId is no longer supported. ${keyIdLower}`;
|
return `Old keyId is no longer supported. ${keyIdLower}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,6 +154,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
if (!err.isRetryable) {
|
if (!err.isRetryable) {
|
||||||
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
|
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
|
||||||
}
|
}
|
||||||
|
incCounter(mIncomingApReject, { reason: 'actor_key_unresolvable' });
|
||||||
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`);
|
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,11 +162,13 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// それでもわからなければ終了
|
// それでもわからなければ終了
|
||||||
if (authUser == null) {
|
if (authUser == null) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'actor_unresolvable' });
|
||||||
throw new Bull.UnrecoverableError('skip: failed to resolve user');
|
throw new Bull.UnrecoverableError('skip: failed to resolve user');
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicKey がなくても終了
|
// publicKey がなくても終了
|
||||||
if (authUser.key == null) {
|
if (authUser.key == null) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'publickey_unresolvable' });
|
||||||
throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey');
|
throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,6 +181,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
const ldSignature = activity.signature;
|
const ldSignature = activity.signature;
|
||||||
if (ldSignature) {
|
if (ldSignature) {
|
||||||
if (ldSignature.type !== 'RsaSignature2017') {
|
if (ldSignature.type !== 'RsaSignature2017') {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'ld_signature_unsupported' });
|
||||||
throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`);
|
throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,10 +195,12 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
// keyIdからLD-Signatureのユーザーを取得
|
// keyIdからLD-Signatureのユーザーを取得
|
||||||
authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator);
|
authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator);
|
||||||
if (authUser == null) {
|
if (authUser == null) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'ld_signature_user_unresolvable' });
|
||||||
throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした');
|
throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authUser.key == null) {
|
if (authUser.key == null) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'ld_signature_publickey_unavailable' });
|
||||||
throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした');
|
throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,6 +209,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
// LD-Signature検証
|
// LD-Signature検証
|
||||||
const verified = await jsonLd.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false);
|
const verified = await jsonLd.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'ld_signature_verification_failed' });
|
||||||
throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました');
|
throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,14 +232,17 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// もう一度actorチェック
|
// もう一度actorチェック
|
||||||
if (authUser.user.uri !== activity.actor) {
|
if (authUser.user.uri !== activity.actor) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'ld_signature_actor_mismatch' });
|
||||||
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
|
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
|
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
|
||||||
if (!this.utilityService.isFederationAllowedHost(ldHost)) {
|
if (!this.utilityService.isFederationAllowedHost(ldHost)) {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'fed_host_not_allowed' });
|
||||||
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
|
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'ld_signature_unavailable' });
|
||||||
throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`);
|
throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -188,8 +252,11 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
const signerHost = this.utilityService.extractDbHost(authUser.user.uri!);
|
const signerHost = this.utilityService.extractDbHost(authUser.user.uri!);
|
||||||
const activityIdHost = this.utilityService.extractDbHost(activity.id);
|
const activityIdHost = this.utilityService.extractDbHost(activity.id);
|
||||||
if (signerHost !== activityIdHost) {
|
if (signerHost !== activityIdHost) {
|
||||||
|
incCounter(mIncomingApReject, 'host_signature_mismatch');
|
||||||
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Bull.UnrecoverableError('skip: activity id is not a string');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apRequestChart.inbox();
|
this.apRequestChart.inbox();
|
||||||
|
@ -215,7 +282,10 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
incCounter(mIncomingApEvent, {});
|
||||||
|
|
||||||
// アクティビティを処理
|
// アクティビティを処理
|
||||||
|
const begin = +new Date();
|
||||||
try {
|
try {
|
||||||
const result = await this.apInboxService.performActivity(authUser.user, activity);
|
const result = await this.apInboxService.performActivity(authUser.user, activity);
|
||||||
if (result && !result.startsWith('ok')) {
|
if (result && !result.startsWith('ok')) {
|
||||||
|
@ -225,17 +295,26 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof IdentifiableError) {
|
if (e instanceof IdentifiableError) {
|
||||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'blocked_notes_with_prohibited_words' });
|
||||||
return 'blocked notes with prohibited words';
|
return 'blocked notes with prohibited words';
|
||||||
}
|
}
|
||||||
if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
||||||
|
incCounter(mIncomingApReject, { reason: 'actor_suspended' });
|
||||||
return 'actor has been suspended';
|
return 'actor has been suspended';
|
||||||
}
|
}
|
||||||
if (e.id === 'd450b8a9-48e4-4dab-ae36-f4db763fda7c') { // invalid Note
|
if (e.id === 'd450b8a9-48e4-4dab-ae36-f4db763fda7c') { // invalid Note
|
||||||
|
incCounter(mIncomingApReject, { reason: 'invalid_note' });
|
||||||
return e.message;
|
return e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const end = +new Date();
|
||||||
|
observeHistogram(mIncomingApProcessingTime, { success: 'false' }, (end - begin) / 1000);
|
||||||
|
incCounter(mincomingApProcessingError, {});
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
observeHistogram(mIncomingApProcessingTime, { success: 'true' }, (+new Date() - begin) / 1000);
|
||||||
|
incCounter(mIncomingApEventAccepted, {});
|
||||||
|
|
||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,7 @@ export class ActivityPubServerService {
|
||||||
let signature;
|
let signature;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reply.code(401);
|
reply.code(401);
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -8,27 +8,19 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import rename from 'rename';
|
import rename from 'rename';
|
||||||
import sharp from 'sharp';
|
|
||||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
|
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { createTemp } from '@/misc/create-temp.js';
|
|
||||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||||
import { StatusError } from '@/misc/status-error.js';
|
import { StatusError } from '@/misc/status-error.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { DownloadService } from '@/core/DownloadService.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';
|
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||||
import { FileInfoService } from '@/core/FileInfoService.js';
|
import { FileInfoService } from '@/core/FileInfoService.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
|
||||||
import { correctFilename } from '@/misc/correct-filename.js';
|
|
||||||
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
|
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
|
import { InternalStorageService } from '@/core/InternalStorageService.js';
|
||||||
|
|
||||||
const _filename = fileURLToPath(import.meta.url);
|
const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
|
@ -46,11 +38,8 @@ export class FileServerService {
|
||||||
@Inject(DI.driveFilesRepository)
|
@Inject(DI.driveFilesRepository)
|
||||||
private driveFilesRepository: DriveFilesRepository,
|
private driveFilesRepository: DriveFilesRepository,
|
||||||
|
|
||||||
private fileInfoService: FileInfoService,
|
|
||||||
private downloadService: DownloadService,
|
|
||||||
private imageProcessingService: ImageProcessingService,
|
|
||||||
private videoProcessingService: VideoProcessingService,
|
|
||||||
private internalStorageService: InternalStorageService,
|
private internalStorageService: InternalStorageService,
|
||||||
|
private fileInfoService: FileInfoService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray');
|
this.logger = this.loggerService.getLogger('server', 'gray');
|
||||||
|
@ -134,159 +123,72 @@ export class FileServerService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (file.state === 'remote') {
|
||||||
if (file.state === 'remote') {
|
const url = new URL(`${this.config.mediaProxy}/`);
|
||||||
let image: IImageStreamable | null = null;
|
|
||||||
|
|
||||||
if (file.fileRole === 'thumbnail') {
|
url.searchParams.set('url', file.url);
|
||||||
if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
|
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
|
||||||
|
|
||||||
const url = new URL(`${this.config.mediaProxy}/static.webp`);
|
return await reply.redirect(url.toString(), 301);
|
||||||
url.searchParams.set('url', file.url);
|
}
|
||||||
url.searchParams.set('static', '1');
|
|
||||||
|
|
||||||
file.cleanup();
|
if (file.fileRole !== 'original') {
|
||||||
return await reply.redirect(url.toString(), 301);
|
const filename = rename(file.filename, {
|
||||||
} else if (file.mime.startsWith('video/')) {
|
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
|
||||||
const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url);
|
extname: file.ext ? `.${file.ext}` : '.unknown',
|
||||||
if (externalThumbnail) {
|
}).toString();
|
||||||
file.cleanup();
|
|
||||||
return await reply.redirect(externalThumbnail, 301);
|
|
||||||
}
|
|
||||||
|
|
||||||
image = await this.videoProcessingService.generateVideoThumbnail(file.path);
|
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));
|
||||||
|
|
||||||
|
if (request.headers.range && file.file.size > 0) {
|
||||||
|
const range = request.headers.range as string;
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||||
|
if (end > file.file.size) {
|
||||||
|
end = file.file.size - 1;
|
||||||
}
|
}
|
||||||
|
const chunksize = end - start + 1;
|
||||||
if (file.fileRole === 'webpublic') {
|
const fileStream = fs.createReadStream(file.path, {
|
||||||
if (['image/svg+xml'].includes(file.mime)) {
|
start,
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
end,
|
||||||
|
});
|
||||||
const url = new URL(`${this.config.mediaProxy}/svg.webp`);
|
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||||
url.searchParams.set('url', file.url);
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
|
reply.header('Content-Length', chunksize);
|
||||||
file.cleanup();
|
reply.code(206);
|
||||||
return await reply.redirect(url.toString(), 301);
|
return fileStream;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
if (request.headers.range && file.file.size > 0) {
|
|
||||||
const range = request.headers.range as string;
|
|
||||||
const parts = range.replace(/bytes=/, '').split('-');
|
|
||||||
const start = parseInt(parts[0], 10);
|
|
||||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
|
||||||
if (end > file.file.size) {
|
|
||||||
end = file.file.size - 1;
|
|
||||||
}
|
|
||||||
const chunksize = end - start + 1;
|
|
||||||
|
|
||||||
image = {
|
|
||||||
data: fs.createReadStream(file.path, {
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}),
|
|
||||||
ext: file.ext,
|
|
||||||
type: file.mime,
|
|
||||||
};
|
|
||||||
|
|
||||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
|
||||||
reply.header('Content-Length', chunksize);
|
|
||||||
reply.code(206);
|
|
||||||
} else {
|
|
||||||
image = {
|
|
||||||
data: fs.createReadStream(file.path),
|
|
||||||
ext: file.ext,
|
|
||||||
type: file.mime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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('Content-Length', file.file.size);
|
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
|
||||||
reply.header('Content-Disposition',
|
|
||||||
contentDisposition(
|
|
||||||
'inline',
|
|
||||||
correctFilename(file.filename, image.ext),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return image.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.fileRole !== 'original') {
|
return fs.createReadStream(file.path);
|
||||||
const filename = rename(file.filename, {
|
} else {
|
||||||
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
|
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
|
||||||
extname: file.ext ? `.${file.ext}` : '.unknown',
|
reply.header('Content-Length', file.file.size);
|
||||||
}).toString();
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
|
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||||
|
|
||||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
|
if (request.headers.range && file.file.size > 0) {
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
const range = request.headers.range as string;
|
||||||
reply.header('Content-Disposition', contentDisposition('inline', filename));
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
if (request.headers.range && file.file.size > 0) {
|
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||||
const range = request.headers.range as string;
|
if (end > file.file.size) {
|
||||||
const parts = range.replace(/bytes=/, '').split('-');
|
end = file.file.size - 1;
|
||||||
const start = parseInt(parts[0], 10);
|
|
||||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
|
||||||
if (end > file.file.size) {
|
|
||||||
end = file.file.size - 1;
|
|
||||||
}
|
|
||||||
const chunksize = end - start + 1;
|
|
||||||
const fileStream = fs.createReadStream(file.path, {
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
});
|
|
||||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
|
||||||
reply.header('Content-Length', chunksize);
|
|
||||||
reply.code(206);
|
|
||||||
return fileStream;
|
|
||||||
}
|
}
|
||||||
|
const chunksize = end - start + 1;
|
||||||
return fs.createReadStream(file.path);
|
const fileStream = fs.createReadStream(file.path, {
|
||||||
} else {
|
start,
|
||||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
|
end,
|
||||||
reply.header('Content-Length', file.file.size);
|
});
|
||||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||||
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
|
reply.header('Content-Length', chunksize);
|
||||||
if (request.headers.range && file.file.size > 0) {
|
reply.code(206);
|
||||||
const range = request.headers.range as string;
|
return fileStream;
|
||||||
const parts = range.replace(/bytes=/, '').split('-');
|
|
||||||
const start = parseInt(parts[0], 10);
|
|
||||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
|
||||||
if (end > file.file.size) {
|
|
||||||
end = file.file.size - 1;
|
|
||||||
}
|
|
||||||
const chunksize = end - start + 1;
|
|
||||||
const fileStream = fs.createReadStream(file.path, {
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
});
|
|
||||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
|
||||||
reply.header('Content-Length', chunksize);
|
|
||||||
reply.code(206);
|
|
||||||
return fileStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.createReadStream(file.path);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if ('cleanup' in file) file.cleanup();
|
return fs.createReadStream(file.path);
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,224 +204,56 @@ export class FileServerService {
|
||||||
// アバタークロップなど、どうしてもオリジンである必要がある場合
|
// アバタークロップなど、どうしてもオリジンである必要がある場合
|
||||||
const mustOrigin = 'origin' in request.query;
|
const mustOrigin = 'origin' in request.query;
|
||||||
|
|
||||||
if (this.config.externalMediaProxyEnabled && !mustOrigin) {
|
if (!this.config.mediaProxy) {
|
||||||
// 外部のメディアプロキシが有効なら、そちらにリダイレクト
|
reply.code(501);
|
||||||
|
}
|
||||||
|
|
||||||
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
|
const proxiedURL = new URL(`${this.config.mediaProxy}/?url=${encodeURIComponent(url)}`);
|
||||||
|
|
||||||
const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`);
|
for (const [key, value] of Object.entries(request.query)) {
|
||||||
|
if (key.toLowerCase() === 'url') continue;
|
||||||
for (const [key, value] of Object.entries(request.query)) {
|
proxiedURL.searchParams.append(key, value);
|
||||||
url.searchParams.append(key, value);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!mustOrigin) {
|
||||||
return await reply.redirect(
|
return await reply.redirect(
|
||||||
url.toString(),
|
proxiedURL.toString(),
|
||||||
301,
|
301,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
|
||||||
|
|
||||||
|
if (!request.headers['user-agent']) {
|
||||||
|
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
|
||||||
|
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
|
||||||
|
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
|
||||||
|
}
|
||||||
|
|
||||||
if (!request.headers['user-agent']) {
|
if (!request.headers['user-agent']) {
|
||||||
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
|
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
|
||||||
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
|
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
|
||||||
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
|
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temp file
|
// directly proxy request through
|
||||||
const file = await this.getStreamAndTypeFromUrl(url);
|
const res = await fetch(proxiedURL, {
|
||||||
if (file === '404') {
|
headers: {
|
||||||
reply.code(404);
|
'X-Forwarded-For': request.headers['x-forwarded-for']?.at(0) ?? request.ip,
|
||||||
reply.header('Cache-Control', 'max-age=86400');
|
'User-Agent': request.headers['user-agent'],
|
||||||
return reply.sendFile('/dummy.png', assets);
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
if (file === '204') {
|
reply.code(res.status);
|
||||||
reply.code(204);
|
for (const [key, value] of res.headers.entries()) {
|
||||||
reply.header('Cache-Control', 'max-age=86400');
|
reply.header(key, value);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
|
|
||||||
const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp');
|
|
||||||
|
|
||||||
if (
|
|
||||||
'emoji' in request.query ||
|
|
||||||
'avatar' in request.query ||
|
|
||||||
'static' in request.query ||
|
|
||||||
'preview' in request.query ||
|
|
||||||
'badge' in request.query
|
|
||||||
) {
|
|
||||||
if (!isConvertibleImage) {
|
|
||||||
// 画像でないなら404でお茶を濁す
|
|
||||||
throw new StatusError('Unexpected mime', 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let image: IImageStreamable | null = null;
|
|
||||||
if ('emoji' in request.query || 'avatar' in request.query) {
|
|
||||||
if (!isAnimationConvertibleImage && !('static' in request.query)) {
|
|
||||||
image = {
|
|
||||||
data: fs.createReadStream(file.path),
|
|
||||||
ext: file.ext,
|
|
||||||
type: file.mime,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
|
|
||||||
.resize({
|
|
||||||
height: 'emoji' in request.query ? 128 : 320,
|
|
||||||
withoutEnlargement: true,
|
|
||||||
})
|
|
||||||
.webp(webpDefault);
|
|
||||||
|
|
||||||
image = {
|
|
||||||
data,
|
|
||||||
ext: 'webp',
|
|
||||||
type: 'image/webp',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if ('static' in request.query) {
|
|
||||||
image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
|
|
||||||
} else if ('preview' in request.query) {
|
|
||||||
image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
|
|
||||||
} else if ('badge' in request.query) {
|
|
||||||
const mask = (await sharpBmp(file.path, file.mime))
|
|
||||||
.resize(96, 96, {
|
|
||||||
fit: 'contain',
|
|
||||||
position: 'centre',
|
|
||||||
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) {
|
|
||||||
if (request.headers.range && file.file && file.file.size > 0) {
|
|
||||||
const range = request.headers.range as string;
|
|
||||||
const parts = range.replace(/bytes=/, '').split('-');
|
|
||||||
const start = parseInt(parts[0], 10);
|
|
||||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
|
||||||
if (end > file.file.size) {
|
|
||||||
end = file.file.size - 1;
|
|
||||||
}
|
|
||||||
const chunksize = end - start + 1;
|
|
||||||
|
|
||||||
image = {
|
|
||||||
data: fs.createReadStream(file.path, {
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}),
|
|
||||||
ext: file.ext,
|
|
||||||
type: file.mime,
|
|
||||||
};
|
|
||||||
|
|
||||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
|
||||||
reply.header('Content-Length', chunksize);
|
|
||||||
reply.code(206);
|
|
||||||
} else {
|
|
||||||
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');
|
|
||||||
reply.header('Content-Disposition',
|
|
||||||
contentDisposition(
|
|
||||||
'inline',
|
|
||||||
correctFilename(file.filename, image.ext),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
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?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
|
|
||||||
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; 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; filename: string; }
|
|
||||||
> {
|
|
||||||
const [path, cleanup] = await createTemp();
|
|
||||||
try {
|
|
||||||
const { filename } = await this.downloadService.downloadUrl(url, path);
|
|
||||||
|
|
||||||
const { mime, ext } = await this.fileInfoService.detectType(path);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: 'remote',
|
|
||||||
mime, ext,
|
|
||||||
path, cleanup,
|
|
||||||
filename,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
cleanup();
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
|
reply.send(res.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async getFileFromKey(key: string): Promise<
|
private async getFileFromKey(key: string): Promise<
|
||||||
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
|
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; filename: string; url: string; }
|
||||||
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; }
|
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; }
|
||||||
| '404'
|
| '404'
|
||||||
| '204'
|
| '204'
|
||||||
|
@ -538,15 +272,10 @@ export class FileServerService {
|
||||||
|
|
||||||
if (!file.storedInternal) {
|
if (!file.storedInternal) {
|
||||||
if (!(file.isLink && file.uri)) return '204';
|
if (!(file.isLink && file.uri)) return '204';
|
||||||
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
|
return { state: 'remote',
|
||||||
file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので
|
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
|
||||||
return {
|
filename: file.name
|
||||||
...result,
|
, url: file.uri };
|
||||||
url: file.uri,
|
|
||||||
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
|
|
||||||
file,
|
|
||||||
filename: file.name,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = this.internalStorageService.resolvePath(key);
|
const path = this.internalStorageService.resolvePath(key);
|
||||||
|
|
|
@ -15,6 +15,8 @@ import NotesChart from '@/core/chart/charts/notes.js';
|
||||||
import UsersChart from '@/core/chart/charts/users.js';
|
import UsersChart from '@/core/chart/charts/users.js';
|
||||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||||
|
import { IsNull, MoreThan, Not } from 'typeorm';
|
||||||
|
import type { NotesRepository, UsersRepository } from '@/models/_.js';
|
||||||
|
|
||||||
const nodeinfo2_1path = '/nodeinfo/2.1';
|
const nodeinfo2_1path = '/nodeinfo/2.1';
|
||||||
const nodeinfo2_0path = '/nodeinfo/2.0';
|
const nodeinfo2_0path = '/nodeinfo/2.0';
|
||||||
|
@ -25,6 +27,10 @@ export class NodeinfoServerService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
@Inject(DI.notesRepository)
|
||||||
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
|
@ -58,18 +64,16 @@ export class NodeinfoServerService {
|
||||||
|
|
||||||
const [
|
const [
|
||||||
meta,
|
meta,
|
||||||
//activeHalfyear,
|
activeHalfyear,
|
||||||
//activeMonth,
|
activeMonth,
|
||||||
|
localComments,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.metaService.fetch(true),
|
this.metaService.fetch(true),
|
||||||
// 重い
|
this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }),
|
||||||
//this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }),
|
this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }),
|
||||||
//this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }),
|
this.notesRepository.count({ where: { userHost: IsNull(), replyId: Not(IsNull()) } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeHalfyear = null;
|
|
||||||
const activeMonth = null;
|
|
||||||
|
|
||||||
const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null;
|
const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null;
|
||||||
|
|
||||||
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
|
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
|
||||||
|
@ -91,7 +95,7 @@ export class NodeinfoServerService {
|
||||||
usage: {
|
usage: {
|
||||||
users: { total, activeHalfyear, activeMonth },
|
users: { total, activeHalfyear, activeMonth },
|
||||||
localPosts,
|
localPosts,
|
||||||
localComments: 0,
|
localComments,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
nodeName: meta.name,
|
nodeName: meta.name,
|
||||||
|
@ -105,6 +109,8 @@ export class NodeinfoServerService {
|
||||||
name: meta.maintainerName,
|
name: meta.maintainerName,
|
||||||
email: meta.maintainerEmail,
|
email: meta.maintainerEmail,
|
||||||
},
|
},
|
||||||
|
gitCommit: this.config.gitCommit,
|
||||||
|
gitDescribe: this.config.gitDescribe,
|
||||||
langs: meta.langs,
|
langs: meta.langs,
|
||||||
tosUrl: meta.termsOfServiceUrl,
|
tosUrl: meta.termsOfServiceUrl,
|
||||||
privacyPolicyUrl: meta.privacyPolicyUrl,
|
privacyPolicyUrl: meta.privacyPolicyUrl,
|
||||||
|
|
|
@ -47,6 +47,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.
|
||||||
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
||||||
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
||||||
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
|
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
|
||||||
|
import { MetricsService } from './api/MetricsService.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -94,6 +95,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
||||||
UserListChannelService,
|
UserListChannelService,
|
||||||
OpenApiServerService,
|
OpenApiServerService,
|
||||||
OAuth2ProviderService,
|
OAuth2ProviderService,
|
||||||
|
MetricsService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ServerService,
|
ServerService,
|
||||||
|
|
|
@ -31,9 +31,89 @@ import { HealthServerService } from './HealthServerService.js';
|
||||||
import { ClientServerService } from './web/ClientServerService.js';
|
import { ClientServerService } from './web/ClientServerService.js';
|
||||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||||
|
import { makeHstsHook } from './hsts.js';
|
||||||
|
import { generateCSP } from './csp.js';
|
||||||
|
import { sanitizeRequestURI } from '@/misc/log-sanitization.js';
|
||||||
|
import { metricCounter, metricGauge, metricHistogram, MetricsService } from './api/MetricsService.js';
|
||||||
|
|
||||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
function categorizeRequestPath(path: string): 'api' | 'health' | 'vite' | 'other' {
|
||||||
|
if (path === '/healthz') {
|
||||||
|
return 'health';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/vite/') || path.startsWith('/embed_vite/')) {
|
||||||
|
return 'vite';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api' || path.startsWith('/api/')) {
|
||||||
|
return 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
const mRequestTime = metricHistogram({
|
||||||
|
name: 'misskey_http_request_duration_seconds',
|
||||||
|
help: 'Duration of handling HTTP requests in seconds',
|
||||||
|
labelNames: ['host', 'cate', 'method', 'path'],
|
||||||
|
buckets: [0.001, 0.1, 0.5, 1, 2, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mRequestsReceived = metricCounter({
|
||||||
|
name: 'misskey_http_requests_received_total',
|
||||||
|
help: 'Total number of HTTP requests received',
|
||||||
|
labelNames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mNotFoundServed = metricCounter({
|
||||||
|
name: 'misskey_http_not_found_served_total',
|
||||||
|
help: 'Total number of HTTP 404 responses served',
|
||||||
|
labelNames: ['method', 'cate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mMethodNotAllowedServed = metricCounter({
|
||||||
|
name: 'misskey_http_method_not_allowed_served_total',
|
||||||
|
help: 'Total number of HTTP 405 responses served',
|
||||||
|
labelNames: ['method', 'cate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mTooManyRequestsServed = metricCounter({
|
||||||
|
name: 'misskey_http_too_many_requests_served_total',
|
||||||
|
help: 'Total number of HTTP 429 responses served',
|
||||||
|
labelNames: ['method', 'cate'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mAggregateRequestsServed = metricCounter({
|
||||||
|
name: 'misskey_http_requests_served_total',
|
||||||
|
help: 'Total number of HTTP requests served including invalid requests',
|
||||||
|
labelNames: ['host', 'cate', 'status'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mRequestsServedByPath = metricCounter({
|
||||||
|
name: 'misskey_http_requests_served_by_path',
|
||||||
|
help: 'Total number of HTTP requests served',
|
||||||
|
labelNames: ['host', 'cate', 'method', 'path', 'status'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mFatalErrorCount = metricCounter({
|
||||||
|
name: 'misskey_fatal_http_errors_total',
|
||||||
|
help: 'Total number of HTTP errors that propagate to the top level',
|
||||||
|
labelNames: ['host', 'cate', 'method', 'path'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mLastSuccessfulRequest = metricGauge({
|
||||||
|
name: 'misskey_http_last_successful_request_timestamp_seconds',
|
||||||
|
help: 'Unix Timestamp of the last successful HTTP request',
|
||||||
|
labelNames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// This function is used to determine if a path is safe to redirect to.
|
||||||
|
function redirectSafePath(path: string): boolean {
|
||||||
|
return ['/files/', '/identicon/', '/proxy/', '/static-assets/', '/vite/', '/embed_vite/'].some(prefix => path.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerService implements OnApplicationShutdown {
|
export class ServerService implements OnApplicationShutdown {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
@ -68,6 +148,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private oauth2ProviderService: OAuth2ProviderService,
|
private oauth2ProviderService: OAuth2ProviderService,
|
||||||
|
private metricsService: MetricsService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray');
|
this.logger = this.loggerService.getLogger('server', 'gray');
|
||||||
}
|
}
|
||||||
|
@ -80,11 +161,152 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
this.#fastify = fastify;
|
this.#fastify = fastify;
|
||||||
|
|
||||||
|
if (this.config.prometheusMetrics?.enable) {
|
||||||
|
fastify.addHook('onRequest', (_request, reply, done) => {
|
||||||
|
reply.header('x-request-received', (+new Date()).toString());
|
||||||
|
mRequestsReceived?.inc();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.addHook('onError', (request, _reply, error, done) => {
|
||||||
|
const url = new URL(request.url, this.config.url);
|
||||||
|
const logPath = sanitizeRequestURI(url.pathname);
|
||||||
|
mFatalErrorCount?.inc({
|
||||||
|
host: request.hostname,
|
||||||
|
method: request.method,
|
||||||
|
path: logPath,
|
||||||
|
cate: categorizeRequestPath(logPath),
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.addHook('onResponse', (request, reply, done) => {
|
||||||
|
const url = new URL(request.url, this.config.url);
|
||||||
|
const logPath = sanitizeRequestURI(url.pathname);
|
||||||
|
const cate = categorizeRequestPath(logPath);
|
||||||
|
const received = reply.getHeader('x-request-received') as string;
|
||||||
|
|
||||||
|
mAggregateRequestsServed?.inc({
|
||||||
|
host: request.hostname,
|
||||||
|
cate,
|
||||||
|
status: reply.statusCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reply.statusCode === 429) {
|
||||||
|
mTooManyRequestsServed?.inc({
|
||||||
|
method: request.method,
|
||||||
|
cate,
|
||||||
|
});
|
||||||
|
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.statusCode === 404) {
|
||||||
|
mNotFoundServed?.inc({
|
||||||
|
method: request.method,
|
||||||
|
cate,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (received) {
|
||||||
|
const duration = (+new Date()) - parseInt(received);
|
||||||
|
mRequestTime?.observe({
|
||||||
|
host: request.hostname,
|
||||||
|
method: request.method,
|
||||||
|
cate,
|
||||||
|
}, duration / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.statusCode === 405) {
|
||||||
|
mMethodNotAllowedServed?.inc({
|
||||||
|
method: request.method,
|
||||||
|
cate,
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (received) {
|
||||||
|
const duration = (+new Date()) - parseInt(received);
|
||||||
|
|
||||||
|
mRequestTime?.observe({
|
||||||
|
host: request.hostname,
|
||||||
|
method: request.method,
|
||||||
|
cate,
|
||||||
|
path: logPath,
|
||||||
|
}, duration / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSlash = logPath + (logPath.endsWith('/') ? '' : '/');
|
||||||
|
|
||||||
|
if (addSlash.startsWith('/metrics/') || addSlash.startsWith('/healthz/')) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.statusCode <= 299) {
|
||||||
|
mLastSuccessfulRequest?.set(+new Date() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
mRequestsServedByPath?.inc({
|
||||||
|
host: request.hostname,
|
||||||
|
method: request.method,
|
||||||
|
path: logPath,
|
||||||
|
cate,
|
||||||
|
status: reply.statusCode >= 500 ? '5xx' :
|
||||||
|
reply.statusCode === 401 ? '401' :
|
||||||
|
reply.statusCode === 403 ? '403' :
|
||||||
|
reply.statusCode >= 400 ?
|
||||||
|
'4xx' : '2xx',
|
||||||
|
});
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// HSTS
|
// HSTS
|
||||||
// 6months (15552000sec)
|
|
||||||
if (this.config.url.startsWith('https') && !this.config.disableHsts) {
|
if (this.config.url.startsWith('https') && !this.config.disableHsts) {
|
||||||
fastify.addHook('onRequest', (request, reply, done) => {
|
const preload = this.config.hstsPreload;
|
||||||
reply.header('strict-transport-security', 'max-age=15552000; preload');
|
const host = new URL(this.config.url).host;
|
||||||
|
fastify.addHook('onRequest', makeHstsHook(host, preload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other Security/Privacy Headers
|
||||||
|
fastify.addHook('onRequest', (_, reply, done) => {
|
||||||
|
reply.header('x-content-type-options', 'nosniff');
|
||||||
|
reply.header('permissions-policy',
|
||||||
|
[
|
||||||
|
'interest-cohort',
|
||||||
|
'encrypted-media',
|
||||||
|
'attribution-reporting',
|
||||||
|
'geolocation', 'microphone', 'camera',
|
||||||
|
'midi', 'payment', 'usb', 'serial',
|
||||||
|
'xr-spatial-tracking'
|
||||||
|
]
|
||||||
|
.map(feature => `${feature}=()`).join(', '));
|
||||||
|
|
||||||
|
if (this.config.browserSandboxing.strictOriginReferrer) {
|
||||||
|
reply.header('referrer-policy', 'strict-origin');
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSP
|
||||||
|
if (process.env.NODE_ENV === 'production' && !this.config.browserSandboxing.csp?.disable) {
|
||||||
|
console.debug('cspPrerenderedContent', this.config.cspPrerenderedContent);
|
||||||
|
const generatedCSP = generateCSP(this.config.cspPrerenderedContent, {
|
||||||
|
mediaProxy: this.config.mediaProxy ? `https://${new URL(this.config.mediaProxy).host}` : undefined,
|
||||||
|
script_src: [
|
||||||
|
`https://${new URL(this.config.url).host}/embed_vite/`,
|
||||||
|
`https://${new URL(this.config.url).host}/vite/`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
fastify.addHook('onRequest', (_, reply, done) => {
|
||||||
|
reply.header('content-security-policy', generatedCSP);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -140,7 +362,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
name: name,
|
name: name,
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
reply.header('Content-Security-Policy', 'default-src \'none\'');
|
||||||
|
|
||||||
if (emoji == null) {
|
if (emoji == null) {
|
||||||
if ('fallback' in request.query) {
|
if ('fallback' in request.query) {
|
||||||
|
@ -151,16 +373,26 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbUrl = emoji?.publicUrl || emoji?.originalUrl;
|
||||||
|
const dbUrlParsed = new URL(dbUrl);
|
||||||
|
const instanceUrl = new URL(this.config.url);
|
||||||
|
if (dbUrlParsed.origin === instanceUrl.origin) {
|
||||||
|
if (!redirectSafePath(dbUrlParsed.pathname)) {
|
||||||
|
return await reply.status(508);
|
||||||
|
}
|
||||||
|
return await reply.redirect(dbUrl, 301);
|
||||||
|
}
|
||||||
|
|
||||||
let url: URL;
|
let url: URL;
|
||||||
if ('badge' in request.query) {
|
if ('badge' in request.query) {
|
||||||
url = new URL(`${this.config.mediaProxy}/emoji.png`);
|
url = new URL(`${this.config.mediaProxy}/emoji.png`);
|
||||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||||
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
|
url.searchParams.set('url', dbUrl);
|
||||||
url.searchParams.set('badge', '1');
|
url.searchParams.set('badge', '1');
|
||||||
} else {
|
} else {
|
||||||
url = new URL(`${this.config.mediaProxy}/emoji.webp`);
|
url = new URL(`${this.config.mediaProxy}/emoji.webp`);
|
||||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||||
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
|
url.searchParams.set('url', dbUrl);
|
||||||
url.searchParams.set('emoji', '1');
|
url.searchParams.set('emoji', '1');
|
||||||
if ('static' in request.query) url.searchParams.set('static', '1');
|
if ('static' in request.query) url.searchParams.set('static', '1');
|
||||||
}
|
}
|
||||||
|
@ -184,6 +416,16 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
reply.header('Cache-Control', 'public, max-age=86400');
|
reply.header('Cache-Control', 'public, max-age=86400');
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
const dbUrl = user?.avatarUrl ?? this.userEntityService.getIdenticonUrl(user);
|
||||||
|
const dbUrlParsed = new URL(dbUrl);
|
||||||
|
const instanceUrl = new URL(this.config.url);
|
||||||
|
if (dbUrlParsed.origin === instanceUrl.origin) {
|
||||||
|
if (!redirectSafePath(dbUrlParsed.pathname)) {
|
||||||
|
return await reply.status(508);
|
||||||
|
}
|
||||||
|
return await reply.redirect(dbUrl, 301);
|
||||||
|
}
|
||||||
|
|
||||||
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
|
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
|
||||||
} else {
|
} else {
|
||||||
reply.redirect('/static-assets/user-unknown.png');
|
reply.redirect('/static-assets/user-unknown.png');
|
||||||
|
@ -227,6 +469,8 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
|
|
||||||
fastify.register(this.clientServerService.createServer);
|
fastify.register(this.clientServerService.createServer);
|
||||||
|
|
||||||
|
fastify.register(this.metricsService.createServer);
|
||||||
|
|
||||||
this.streamingApiServerService.attach(fastify.server);
|
this.streamingApiServerService.attach(fastify.server);
|
||||||
|
|
||||||
fastify.server.on('error', err => {
|
fastify.server.on('error', err => {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/_.js';
|
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/_.js';
|
||||||
import type { MiLocalUser } from '@/models/User.js';
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
import type { MiAccessToken } from '@/models/AccessToken.js';
|
import type { MiAccessToken } from '@/models/AccessToken.js';
|
||||||
|
@ -13,6 +14,7 @@ import type { MiApp } from '@/models/App.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import isNativeToken from '@/misc/is-native-token.js';
|
import isNativeToken from '@/misc/is-native-token.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { metricCounter } from './MetricsService.js';
|
||||||
|
|
||||||
export class AuthenticationError extends Error {
|
export class AuthenticationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
@ -21,6 +23,12 @@ export class AuthenticationError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mAuthenticationFailureCounter = metricCounter({
|
||||||
|
name: 'misskey_authentication_failure_total',
|
||||||
|
help: 'Total number of authentication failures',
|
||||||
|
labelNames: ['cred_ty'],
|
||||||
|
});
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticateService implements OnApplicationShutdown {
|
export class AuthenticateService implements OnApplicationShutdown {
|
||||||
private appCache: MemoryKVCache<MiApp>;
|
private appCache: MemoryKVCache<MiApp>;
|
||||||
|
@ -51,6 +59,7 @@ export class AuthenticateService implements OnApplicationShutdown {
|
||||||
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>);
|
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
|
mAuthenticationFailureCounter?.inc({ cred_ty: 'native' });
|
||||||
throw new AuthenticationError('user not found');
|
throw new AuthenticationError('user not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,6 +74,7 @@ export class AuthenticateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (accessToken == null) {
|
if (accessToken == null) {
|
||||||
|
mAuthenticationFailureCounter?.inc({ cred_ty: 'access_token' });
|
||||||
throw new AuthenticationError('invalid signature');
|
throw new AuthenticationError('invalid signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
112
packages/backend/src/server/api/MetricsService.ts
Normal file
112
packages/backend/src/server/api/MetricsService.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import * as prom from 'prom-client';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import { bindThis } from "@/decorators.js";
|
||||||
|
import type { FastifyInstance, FastifyPluginOptions } from "fastify";
|
||||||
|
|
||||||
|
export function metricGauge<K extends string>(conf: prom.GaugeConfiguration<K>) : prom.Gauge<K> | null {
|
||||||
|
if (!process.env.RUN_MODE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new prom.Gauge(conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metricCounter<K extends string>(conf: prom.CounterConfiguration<K>) : prom.Counter<K> | null {
|
||||||
|
if (!process.env.RUN_MODE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new prom.Counter(conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metricHistogram<K extends string>(conf: prom.HistogramConfiguration<K>) : prom.Histogram<K> | null {
|
||||||
|
if (!process.env.RUN_MODE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new prom.Histogram(conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project and yumechi
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsService {
|
||||||
|
private workerRegistry: prom.AggregatorRegistry<prom.PrometheusContentType> | null = null;
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public setWorkerRegistry(workerRegistry: prom.AggregatorRegistry<prom.PrometheusContentType>) {
|
||||||
|
this.workerRegistry = workerRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
|
if (this.config.prometheusMetrics?.enable) {
|
||||||
|
const token = this.config.prometheusMetrics.scrapeToken;
|
||||||
|
fastify.get('/metrics', async (request, reply) => {
|
||||||
|
if (token) {
|
||||||
|
const bearer = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!bearer) {
|
||||||
|
reply.code(401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, t] = bearer.split(' ');
|
||||||
|
|
||||||
|
if (type !== 'Bearer' || t !== token) {
|
||||||
|
reply.code(403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
reply.header('Content-Type', prom.register.contentType);
|
||||||
|
reply.send(await prom.register.metrics());
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/metrics/cluster', async (request, reply) => {
|
||||||
|
if (token) {
|
||||||
|
const bearer = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!bearer) {
|
||||||
|
reply.code(401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, t] = bearer.split(' ');
|
||||||
|
|
||||||
|
if (type !== 'Bearer' || t !== token) {
|
||||||
|
reply.code(403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.workerRegistry) {
|
||||||
|
reply.code(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
reply.header('Content-Type', this.workerRegistry.contentType);
|
||||||
|
reply.send(await this.workerRegistry.clusterMetrics());
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
|
import * as prom from 'prom-client';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type {
|
import type {
|
||||||
|
@ -28,6 +29,13 @@ import { RateLimiterService } from './RateLimiterService.js';
|
||||||
import { SigninService } from './SigninService.js';
|
import { SigninService } from './SigninService.js';
|
||||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { metricCounter } from './MetricsService.js';
|
||||||
|
|
||||||
|
const mSigninFailureCounter = metricCounter({
|
||||||
|
name: 'misskey_misskey_signin_failure',
|
||||||
|
help: 'The number of failed sign-ins',
|
||||||
|
labelNames: ['reason'],
|
||||||
|
});
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SigninApiService {
|
export class SigninApiService {
|
||||||
|
@ -93,6 +101,7 @@ export class SigninApiService {
|
||||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||||
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'rate_limit' });
|
||||||
reply.code(429);
|
reply.code(429);
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
|
@ -104,11 +113,13 @@ export class SigninApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof username !== 'string') {
|
if (typeof username !== 'string') {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'bad_form' });
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token != null && typeof token !== 'string') {
|
if (token != null && typeof token !== 'string') {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'bad_form' });
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -120,12 +131,14 @@ export class SigninApiService {
|
||||||
}) as MiLocalUser;
|
}) as MiLocalUser;
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'user_not_found' });
|
||||||
return error(404, {
|
return error(404, {
|
||||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'user_suspended' });
|
||||||
return error(403, {
|
return error(403, {
|
||||||
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
||||||
});
|
});
|
||||||
|
@ -150,6 +163,7 @@ export class SigninApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof password !== 'string') {
|
if (typeof password !== 'string') {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'bad_form' });
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -167,6 +181,7 @@ export class SigninApiService {
|
||||||
success: false,
|
success: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mSigninFailureCounter?.inc({ reason: failure?.id ?? `unknown_error_${status ?? 500}` });
|
||||||
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
|
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -174,30 +189,35 @@ export class SigninApiService {
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
|
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
|
||||||
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'captcha_verification_failed_hcaptcha' });
|
||||||
throw new FastifyReplyError(400, err);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
|
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
|
||||||
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
|
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'captcha_verification_failed_mcaptcha' });
|
||||||
throw new FastifyReplyError(400, err);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
|
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
|
||||||
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'captcha_verification_failed_recaptcha' });
|
||||||
throw new FastifyReplyError(400, err);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
|
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
|
||||||
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
|
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'captcha_verification_failed_turnstile' });
|
||||||
throw new FastifyReplyError(400, err);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.meta.enableTestcaptcha) {
|
if (this.meta.enableTestcaptcha) {
|
||||||
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
|
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
|
||||||
|
mSigninFailureCounter?.inc({ reason: 'captcha_verification_failed_testcaptcha' });
|
||||||
throw new FastifyReplyError(400, err);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -235,7 +255,7 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
|
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential, !profile.usePasswordLessLogin);
|
||||||
|
|
||||||
if (authorized) {
|
if (authorized) {
|
||||||
return this.signinService.signin(request, reply, user);
|
return this.signinService.signin(request, reply, user);
|
||||||
|
|
|
@ -15,18 +15,21 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
import { Packed } from '@/misc/json-schema.js';
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
accessDenied: {
|
accessDenied: {
|
||||||
|
httpStatusCode: 403,
|
||||||
message: 'Access denied.',
|
message: 'Access denied.',
|
||||||
code: 'ACCESS_DENIED',
|
code: 'ACCESS_DENIED',
|
||||||
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||||
},
|
},
|
||||||
|
|
||||||
wrongInitialPassword: {
|
wrongInitialPassword: {
|
||||||
|
httpStatusCode: 401,
|
||||||
message: 'Initial password is incorrect.',
|
message: 'Initial password is incorrect.',
|
||||||
code: 'INCORRECT_INITIAL_PASSWORD',
|
code: 'INCORRECT_INITIAL_PASSWORD',
|
||||||
id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62',
|
id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62',
|
||||||
|
@ -65,6 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
private roleService: RoleService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private signupService: SignupService,
|
private signupService: SignupService,
|
||||||
private instanceActorService: InstanceActorService,
|
private instanceActorService: InstanceActorService,
|
||||||
|
@ -85,8 +89,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
|
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
|
||||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||||
}
|
}
|
||||||
} else if ((realUsers && !me?.isRoot) || token !== null) {
|
} else if (!(me?.isRoot) && !await this.roleService.isAdministrator(me)) {
|
||||||
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
|
// 管理者でない場合
|
||||||
|
throw new ApiError(meta.errors.accessDenied);
|
||||||
|
} else if (token && !token?.permission.includes('write:admin:create-account')) {
|
||||||
|
// access token を使うときは write:admin:create-account 権限が必要
|
||||||
throw new ApiError(meta.errors.accessDenied);
|
throw new ApiError(meta.errors.accessDenied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['federation'],
|
tags: ['federation'],
|
||||||
|
|
||||||
|
requireAdmin: true,
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
kind: 'read:federation',
|
kind: 'read:federation',
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue