Merge branch 'oscar' into postVisibility

This commit is contained in:
ASTRO:? 2024-12-31 21:00:59 +09:00
commit 32bb07d5d0
No known key found for this signature in database
GPG key ID: 8947F3AF5B0B4BFE
143 changed files with 1773 additions and 1039 deletions

View file

@ -1 +1 @@
FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 FROM mcr.microsoft.com/devcontainers/javascript-node:22

View file

@ -4,10 +4,10 @@
"service": "app", "service": "app",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:latest": {
"version": "20" "version": "22"
}, },
"ghcr.io/devcontainers-contrib/features/corepack:1": {} "ghcr.io/devcontainers-contrib/features/pnpm:latest": {}
}, },
"forwardPorts": [3000], "forwardPorts": [3000],
"postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh",

View file

@ -4,8 +4,6 @@ set -xe
sudo chown -R node /workspace sudo chown -R node /workspace
git submodule update --init git submodule update --init
corepack install
corepack enable
pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm config set store-dir /home/node/.local/share/pnpm/store
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml cp .devcontainer/devcontainer.yml .config/default.yml

View file

@ -1,3 +1,5 @@
**/.git
.autogen .autogen
.github .github
.travis .travis
@ -7,24 +9,15 @@ Dockerfile
build/ build/
built/ built/
db/ db/
compose.yml
docker-compose.yml docker-compose.yml
node_modules/ node_modules/
packages/*/node_modules packages/*/node_modules
redis/ redis/
files/ files/
fluent-emojis/
.pnp.*
# .yarn関連
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnpm-store .pnpm-store
.idea/ .idea/
packages/*/.vscode/ packages/*/.vscode/
packages/backend/test/compose.yml
packages/backend/test/docker-compose.yml packages/backend/test/docker-compose.yml

View file

@ -46,7 +46,7 @@ jobs:
- 56312:6379 - 56312:6379
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -61,7 +61,6 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml - name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml
@ -108,7 +107,7 @@ jobs:
- 56312:6379 - 56312:6379
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -121,7 +120,6 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml - name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml

View file

@ -15,9 +15,15 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- run: corepack enable - name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.1.0 uses: actions/setup-node@v4.1.0

View file

@ -19,7 +19,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Check version - name: Check version
run: | run: |
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then

0
.github/workflows/docker-beta.yml vendored Normal file
View file

View file

@ -16,6 +16,9 @@ jobs:
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View file

@ -12,6 +12,9 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Build an image from Dockerfile - name: Build an image from Dockerfile
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:

View file

@ -22,7 +22,7 @@ jobs:
pnpm_install: pnpm_install:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -33,7 +33,6 @@ jobs:
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
lint: lint:
@ -48,7 +47,7 @@ jobs:
- sw - sw
- misskey-js - misskey-js
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -59,7 +58,6 @@ jobs:
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- run: pnpm --filter ${{ matrix.workspace }} run eslint - run: pnpm --filter ${{ matrix.workspace }} run eslint
@ -73,7 +71,7 @@ jobs:
- backend - backend
- misskey-js - misskey-js
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -84,7 +82,6 @@ jobs:
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- run: pnpm -r run build:tsc - run: pnpm -r run build:tsc
if: ${{ matrix.workspace == 'backend' }} if: ${{ matrix.workspace == 'backend' }}

View file

@ -28,7 +28,7 @@ jobs:
node-version: [22.x] node-version: [22.x]
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -41,7 +41,6 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml - name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml

View file

@ -25,9 +25,15 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- run: corepack enable - name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js ${{ matrix.node-version }} - name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.1.0 uses: actions/setup-node@v4.1.0

View file

@ -18,7 +18,7 @@ jobs:
node-version: [22.x] node-version: [22.x]
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -31,7 +31,6 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml - name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml

View file

@ -19,7 +19,7 @@ jobs:
node-version: [22.x] node-version: [22.x]
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
@ -34,7 +34,6 @@ jobs:
cache: 'pnpm' cache: 'pnpm'
- name: Install Redocly CLI - name: Install Redocly CLI
run: npm i -g @redocly/cli run: npm i -g @redocly/cli
- run: corepack enable
- run: pnpm i --frozen-lockfile - run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml - name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml

14
.gitignore vendored
View file

@ -9,17 +9,6 @@
node_modules node_modules
report.*.json report.*.json
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
packages/frontend/.yarn/cache
packages/backend/.yarn/cache
packages/sw/.yarn/cache
# pnpm # pnpm
.pnpm-store .pnpm-store
@ -35,8 +24,11 @@ coverage
!/.config/example.yml !/.config/example.yml
!/.config/docker_example.yml !/.config/docker_example.yml
!/.config/docker_example.env !/.config/docker_example.env
compose.yml
docker-compose.yml docker-compose.yml
!/.devcontainer/compose.yml
!/.devcontainer/docker-compose.yml !/.devcontainer/docker-compose.yml
!/packages/backend/test/compose.yml
!/packages/backend/test/docker-compose.yml !/packages/backend/test/docker-compose.yml
# misskey # misskey

View file

@ -14,11 +14,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& apt-get install -yqq --no-install-recommends \ && apt-get install -yqq --no-install-recommends \
build-essential build-essential
RUN corepack enable
WORKDIR /misskey WORKDIR /misskey
COPY --link pnpm-lock.yaml ./ COPY --link pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm fetch --ignore-scripts pnpm fetch --ignore-scripts
@ -36,11 +35,7 @@ RUN pnpm i --frozen-lockfile --aggregate-output --offline \
COPY --link . ./ COPY --link . ./
ARG NODE_ENV=production RUN NODE_ENV=production pnpm build
RUN git submodule update --init
RUN pnpm build
RUN rm -rf .git/
# build native dependencies for target platform # build native dependencies for target platform
@ -50,11 +45,10 @@ RUN apt-get update \
&& apt-get install -yqq --no-install-recommends \ && apt-get install -yqq --no-install-recommends \
build-essential build-essential
RUN corepack enable
WORKDIR /misskey WORKDIR /misskey
COPY --link pnpm-lock.yaml ./ COPY --link pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm fetch --ignore-scripts pnpm fetch --ignore-scripts
@ -75,9 +69,8 @@ ARG GID="991"
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
curl ffmpeg libjemalloc-dev libjemalloc2 tini \ curl ffmpeg libjemalloc-dev libjemalloc2 tini \
&& ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \ && ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \
&& corepack enable \
&& groupadd -g "${GID}" misskey \ && groupadd -g "${GID}" misskey \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \
@ -85,9 +78,11 @@ RUN apt-get update \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists && rm -rf /var/lib/apt/lists
USER misskey
WORKDIR /misskey WORKDIR /misskey
COPY --chown=misskey:misskey pnpm-lock.yaml ./
RUN npm install -g pnpm
COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
@ -101,13 +96,11 @@ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/bui
COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis
COPY --chown=misskey:misskey . ./ COPY --chown=misskey:misskey . ./
RUN corepack install \ USER misskey
&& corepack pack
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
ENV MALLOC_CONF=background_thread:true,metadata_thp:auto,dirty_decay_ms:30000,muzzy_decay_ms:30000 ENV MALLOC_CONF=background_thread:true,metadata_thp:auto,dirty_decay_ms:30000,muzzy_decay_ms:30000
ENV TF_CPP_MIN_LOG_LEVEL=2
ENV NODE_ENV=production ENV NODE_ENV=production
ENV COREPACK_ENABLE_NETWORK=0
HEALTHCHECK --interval=5s --retries=20 CMD ["/bin/bash", "/misskey/healthcheck.sh"] HEALTHCHECK --interval=5s --retries=20 CMD ["/bin/bash", "/misskey/healthcheck.sh"]
ENTRYPOINT ["/usr/bin/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["pnpm", "run", "migrateandstart:docker"] CMD ["pnpm", "run", "migrateandstart:docker"]

View file

@ -822,6 +822,7 @@ unmuteThread: "ارفع الكتم عن النقاش"
continueThread: "اعرض بقية النقاش" continueThread: "اعرض بقية النقاش"
deleteAccountConfirm: "سيحذف حسابك نهائيًا، أتريد المتابعة؟" deleteAccountConfirm: "سيحذف حسابك نهائيًا، أتريد المتابعة؟"
incorrectPassword: "كلمة السر خاطئة." incorrectPassword: "كلمة السر خاطئة."
authenticationFailed: "فشل التوثيق"
voteConfirm: "متيقِّن من تصويتك لـ {choice}؟" voteConfirm: "متيقِّن من تصويتك لـ {choice}؟"
hide: "إخفاء" hide: "إخفاء"
welcomeBackWithName: "مرحبًا بك مجددًا {name}" welcomeBackWithName: "مرحبًا بك مجددًا {name}"

View file

@ -815,6 +815,7 @@ unmuteThread: "থ্রেড আনমিউট করুন"
continueThread: "আরো থ্রেড দেখুন" continueThread: "আরো থ্রেড দেখুন"
deleteAccountConfirm: "আপনার অ্যাকাউন্ট মুছে ফেলা হবে। ঠিক আছে?" deleteAccountConfirm: "আপনার অ্যাকাউন্ট মুছে ফেলা হবে। ঠিক আছে?"
incorrectPassword: "আপনার দেওয়া পাসওয়ার্ডটি ভুল।" incorrectPassword: "আপনার দেওয়া পাসওয়ার্ডটি ভুল।"
authenticationFailed: "প্রমাণীকরণ ব্যর্থ হয়েছে।"
voteConfirm: "\"{choice}\" এ ভোট দিতে চান?" voteConfirm: "\"{choice}\" এ ভোট দিতে চান?"
hide: "লুকান" hide: "লুকান"
useDrawerReactionPickerForMobile: "মোবাইলে রিঅ্যাকশন পিকারকে ড্রয়ারে প্রদর্শন করুন" useDrawerReactionPickerForMobile: "মোবাইলে রিঅ্যাকশন পিকারকে ড্রয়ারে প্রদর্শন করুন"

View file

@ -896,6 +896,7 @@ followersVisibility: "Visibilitat dels seguidors"
continueThread: "Veure la continuació del fil" continueThread: "Veure la continuació del fil"
deleteAccountConfirm: "Això eliminarà el teu compte irreversiblement. Procedir?" deleteAccountConfirm: "Això eliminarà el teu compte irreversiblement. Procedir?"
incorrectPassword: "Contrasenya incorrecta." incorrectPassword: "Contrasenya incorrecta."
authenticationFailed: "Autenticació fallida."
voteConfirm: "Confirma el teu vot \"{choice}\"" voteConfirm: "Confirma el teu vot \"{choice}\""
hide: "Amagar" hide: "Amagar"
useDrawerReactionPickerForMobile: "Mostrar el selector de reaccions com un calaix al mòbil " useDrawerReactionPickerForMobile: "Mostrar el selector de reaccions com un calaix al mòbil "

View file

@ -859,6 +859,7 @@ unmuteThread: "Zrušit ztlumení vlákna"
continueThread: "Zobrazit pokračování vlákna" continueThread: "Zobrazit pokračování vlákna"
deleteAccountConfirm: "Tohle nenávratně smaže váš účet, chcete pokračovat?" deleteAccountConfirm: "Tohle nenávratně smaže váš účet, chcete pokračovat?"
incorrectPassword: "Nesprávné heslo." incorrectPassword: "Nesprávné heslo."
authenticationFailed: "Ověření selhalo."
voteConfirm: "Potvrdit hlas pro \"{choice}\"?" voteConfirm: "Potvrdit hlas pro \"{choice}\"?"
hide: "Skrýt" hide: "Skrýt"
useDrawerReactionPickerForMobile: "Zobrazit výběr reakcí jako šuplík na mobilním zařízení" useDrawerReactionPickerForMobile: "Zobrazit výběr reakcí jako šuplík na mobilním zařízení"

View file

@ -889,6 +889,7 @@ unmuteThread: "Threadstummschaltung aufheben"
continueThread: "Weiteren Threadverlauf anzeigen" continueThread: "Weiteren Threadverlauf anzeigen"
deleteAccountConfirm: "Dein Benutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?" deleteAccountConfirm: "Dein Benutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?"
incorrectPassword: "Falsches Passwort." incorrectPassword: "Falsches Passwort."
authenticationFailed: "Authentifizierung fehlgeschlagen."
voteConfirm: "Wirklich für „{choice}“ abstimmen?" voteConfirm: "Wirklich für „{choice}“ abstimmen?"
hide: "Inhalt verbergen" hide: "Inhalt verbergen"
useDrawerReactionPickerForMobile: "Auf mobilen Geräten ausfahrbare Reaktionsauswahl anzeigen" useDrawerReactionPickerForMobile: "Auf mobilen Geräten ausfahrbare Reaktionsauswahl anzeigen"

View file

@ -920,6 +920,7 @@ continueThread: "View thread continuation"
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?" deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
deleteAccountConfirmAndWarn: "This will irreversibly delete your account.\nPlease note that re-logging in after a deletion request will interrupt the deletion of your account.\nProceed?" deleteAccountConfirmAndWarn: "This will irreversibly delete your account.\nPlease note that re-logging in after a deletion request will interrupt the deletion of your account.\nProceed?"
incorrectPassword: "Incorrect password." incorrectPassword: "Incorrect password."
authenticationFailed: "Authentication failed."
voteConfirm: "Confirm your vote for \"{choice}\"?" voteConfirm: "Confirm your vote for \"{choice}\"?"
hide: "Hide" hide: "Hide"
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile" useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
@ -1299,6 +1300,7 @@ yourNameContainsProhibitedWordsDescription: "If you wish to use this name, pleas
thisContentsAreMarkedAsSigninRequiredByAuthor: "Set by the author to require login to view" thisContentsAreMarkedAsSigninRequiredByAuthor: "Set by the author to require login to view"
lockdown: "Lockdown" lockdown: "Lockdown"
pleaseSelectAccount: "Select an account" pleaseSelectAccount: "Select an account"
availableRoles: "Available roles"
here: "here" here: "here"
credits: "Closing Credits" credits: "Closing Credits"
timeWillCome: "Wondering your name on here someday." timeWillCome: "Wondering your name on here someday."
@ -1336,6 +1338,9 @@ muteNotification: "Mute notifications"
unmuteNotification: "Unmute notifications" unmuteNotification: "Unmute notifications"
endingCreditMembers: "Users to display in the closing credits" endingCreditMembers: "Users to display in the closing credits"
endingCreditMembersDescription: "IDs of users to display on the server information page, one per line." endingCreditMembersDescription: "IDs of users to display on the server information page, one per line."
emailAddressLogin: "Login with email address"
usernameLogin: "Login with username"
_bubbleGame: _bubbleGame:
howToPlay: "How to play" howToPlay: "How to play"
hold: "Hold" hold: "Hold"
@ -2476,6 +2481,9 @@ _pages:
contentBlocks: "Content" contentBlocks: "Content"
inputBlocks: "Input" inputBlocks: "Input"
specialBlocks: "Special" specialBlocks: "Special"
visibility: "Visibility"
public: "Public"
private: "Private"
blocks: blocks:
text: "Text" text: "Text"
textarea: "Text area" textarea: "Text area"
@ -2777,6 +2785,12 @@ _skebStatus:
yenX: "JPY {x}" yenX: "JPY {x}"
nWorks: "Delivered {n} works" nWorks: "Delivered {n} works"
nRequests: "Requested {n} times" nRequests: "Requested {n} times"
_selfXssPrevention:
warning: "Warning"
title: "All \"Paste something on this screen\" requests are *SCAMS*."
description1: "If you paste something here, you may be at risk of having your account hijacked or your personal information stolen by malicious users."
description2: "If you do not understand exactly what you are trying to paste, %cstop immediately and close this window."
description3: "For more information, please check here. {link}"
_hideSensitiveInformation: _hideSensitiveInformation:
use: "Enable 'Private Mode'" use: "Enable 'Private Mode'"
about: "Enabling this feature can help protect your privacy when others are watching your screen, or when you're using Misskey in public, for example." about: "Enabling this feature can help protect your privacy when others are watching your screen, or when you're using Misskey in public, for example."

View file

@ -896,6 +896,7 @@ followersVisibility: "Visibilidad de seguidores"
continueThread: "Ver la continuación del hilo" continueThread: "Ver la continuación del hilo"
deleteAccountConfirm: "La cuenta será borrada. ¿Está seguro?" deleteAccountConfirm: "La cuenta será borrada. ¿Está seguro?"
incorrectPassword: "La contraseña es incorrecta" incorrectPassword: "La contraseña es incorrecta"
authenticationFailed: "La autenticación falló"
voteConfirm: "¿Confirma su voto a {choice}?" voteConfirm: "¿Confirma su voto a {choice}?"
hide: "Ocultar" hide: "Ocultar"
useDrawerReactionPickerForMobile: "Mostrar panel de reacciones en móviles" useDrawerReactionPickerForMobile: "Mostrar panel de reacciones en móviles"

View file

@ -896,6 +896,7 @@ followersVisibility: "Visibilité des abonnés"
continueThread: "Afficher la suite du fil" continueThread: "Afficher la suite du fil"
deleteAccountConfirm: "Votre compte sera supprimé. Êtes vous certain ?" deleteAccountConfirm: "Votre compte sera supprimé. Êtes vous certain ?"
incorrectPassword: "Le mot de passe est incorrect." incorrectPassword: "Le mot de passe est incorrect."
authenticationFailed: "L'authentification a échoué."
voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?" voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?"
hide: "Masquer" hide: "Masquer"
useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que panneau sur mobile" useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que panneau sur mobile"

View file

@ -896,6 +896,7 @@ followersVisibility: "Visibilitas pengikut"
continueThread: "Lihat lanjutan thread" continueThread: "Lihat lanjutan thread"
deleteAccountConfirm: "Akun akan dihapus. Apakah kamu yakin?" deleteAccountConfirm: "Akun akan dihapus. Apakah kamu yakin?"
incorrectPassword: "Kata sandi salah." incorrectPassword: "Kata sandi salah."
authenticationFailed: "Autentikasi gagal."
voteConfirm: "Konfirmasi suara kamu untuk ({choice})" voteConfirm: "Konfirmasi suara kamu untuk ({choice})"
hide: "Sembunyikan" hide: "Sembunyikan"
useDrawerReactionPickerForMobile: "Tampilkan bilah reaksi sebagai laci di ponsel" useDrawerReactionPickerForMobile: "Tampilkan bilah reaksi sebagai laci di ponsel"

4
locales/index.d.ts vendored
View file

@ -3733,6 +3733,10 @@ export interface Locale extends ILocale {
* *
*/ */
"incorrectPassword": string; "incorrectPassword": string;
/**
*
*/
"authenticationFailed": string;
/** /**
* {choice} * {choice}
*/ */

View file

@ -896,6 +896,7 @@ followersVisibility: "Visibilità dei profili che ti seguono"
continueThread: "Altre conversazioni" continueThread: "Altre conversazioni"
deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?" deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?"
incorrectPassword: "La password è errata." incorrectPassword: "La password è errata."
authenticationFailed: "Autenticazione fallita"
voteConfirm: "Votare per「{choice}」?" voteConfirm: "Votare per「{choice}」?"
hide: "Nascondere" hide: "Nascondere"
useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile" useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile"

View file

@ -928,6 +928,7 @@ deleteAccountConfirm: "アカウントが削除されます。よろしいです
truncateAccountConfirm: "ダイレクトとピン留めされたノート、関連ドライブのファイルを除くすべてのノートとドライブのファイルが削除されます。続行しますか?" truncateAccountConfirm: "ダイレクトとピン留めされたノート、関連ドライブのファイルを除くすべてのノートとドライブのファイルが削除されます。続行しますか?"
deleteAccountConfirmAndWarn: "アカウントが削除されます。\n削除リクエスト後に再ログインすると\nアカウントの削除が中断されてしまいますのでご注意ください。\nよろしいですか" deleteAccountConfirmAndWarn: "アカウントが削除されます。\n削除リクエスト後に再ログインすると\nアカウントの削除が中断されてしまいますのでご注意ください。\nよろしいですか"
incorrectPassword: "パスワードが間違っています。" incorrectPassword: "パスワードが間違っています。"
authenticationFailed: "認証に失敗しました。"
voteConfirm: "「{choice}」に投票しますか?" voteConfirm: "「{choice}」に投票しますか?"
hide: "隠す" hide: "隠す"
useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示" useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示"

View file

@ -896,6 +896,7 @@ followersVisibility: "フォロワーの公開範囲"
continueThread: "さらにスレッドを見るで" continueThread: "さらにスレッドを見るで"
deleteAccountConfirm: "アカウントを消すで?ええんか?" deleteAccountConfirm: "アカウントを消すで?ええんか?"
incorrectPassword: "パスワードがちゃうわ。" incorrectPassword: "パスワードがちゃうわ。"
authenticationFailed: "認証失敗したで。"
voteConfirm: "「{choice}」に投票するんか?" voteConfirm: "「{choice}」に投票するんか?"
hide: "隠す" hide: "隠す"
useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで" useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで"

View file

@ -922,6 +922,7 @@ deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다.
truncateAccountConfirm: "다이렉트 및 프로필 상단에 고정된 노트를 제외한 모든 노트가 (드라이브 정리 옵션을 켠 경우 모든 파일도) 삭제되고 이는 복구할 수 없습니다. 그래도 계속하시겠습니까?" truncateAccountConfirm: "다이렉트 및 프로필 상단에 고정된 노트를 제외한 모든 노트가 (드라이브 정리 옵션을 켠 경우 모든 파일도) 삭제되고 이는 복구할 수 없습니다. 그래도 계속하시겠습니까?"
deleteAccountConfirmAndWarn: "계정이 삭제됩니다.\n삭제 요청 후 다시 로그인하면 계정 삭제가 중단되어 버립니다.\n계속하시겠습니까?" deleteAccountConfirmAndWarn: "계정이 삭제됩니다.\n삭제 요청 후 다시 로그인하면 계정 삭제가 중단되어 버립니다.\n계속하시겠습니까?"
incorrectPassword: "비밀번호가 올바르지 않습니다." incorrectPassword: "비밀번호가 올바르지 않습니다."
authenticationFailed: "인증에 실패했습니다."
voteConfirm: "\"{choice}\"에 투표하시겠습니까?" voteConfirm: "\"{choice}\"에 투표하시겠습니까?"
hide: "숨기기" hide: "숨기기"
useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시" useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시"
@ -1300,6 +1301,8 @@ prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드
yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다." yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요." yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
lockdown: "잠금" lockdown: "잠금"
pleaseSelectAccount: "계정을 선택해 주세요"
availableRoles: "사용 가능한 역할"
here: "여기" here: "여기"
alwaysConfirmFollow: "팔로우할 때 항상 확인하기" alwaysConfirmFollow: "팔로우할 때 항상 확인하기"
credits: "엔딩 크레딧" credits: "엔딩 크레딧"
@ -1338,7 +1341,6 @@ dangerZone: "위험한 것들"
dangerZoneDescription: "함부로 실행하면 어딘가 고장날 수 있는 설정들이니, 실행할 때는 주의하세요." dangerZoneDescription: "함부로 실행하면 어딘가 고장날 수 있는 설정들이니, 실행할 때는 주의하세요."
checkedByHIBP: "비밀번호의 안전성과 더불어, HIBP를 통해 비밀번호 유출을 검사합니다." checkedByHIBP: "비밀번호의 안전성과 더불어, HIBP를 통해 비밀번호 유출을 검사합니다."
changeUserName: "이름 변경" changeUserName: "이름 변경"
pleaseSelectAccount: "사용할 계정을 선택해주십시오"
normalize: "정상화" normalize: "정상화"
normalizeConfirm: "정상화 이후에는 계정을 되돌릴 수 없게 됩니다. 실행하시겠습니까?" normalizeConfirm: "정상화 이후에는 계정을 되돌릴 수 없게 됩니다. 실행하시겠습니까?"
normalizeDescription: "정상화는 유저의 일괄적인 데이터 말소 및 계정 정지를 위한 기능으로, 계정 삭제와 비슷한 효과를 가지며, 실행 후에는 모든 신고가 닫히게 됩니다. 기능을 실행하고 나면 되돌릴 수 없는 점을 유의하시기 바랍니다." normalizeDescription: "정상화는 유저의 일괄적인 데이터 말소 및 계정 정지를 위한 기능으로, 계정 삭제와 비슷한 효과를 가지며, 실행 후에는 모든 신고가 닫히게 됩니다. 기능을 실행하고 나면 되돌릴 수 없는 점을 유의하시기 바랍니다."
@ -1361,6 +1363,8 @@ muteNotification: "알림을 뮤트하기"
unmuteNotification: "알림 뮤트를 해제하기" unmuteNotification: "알림 뮤트를 해제하기"
endingCreditMembers: "엔딩 크레딧에 표시할 유저" endingCreditMembers: "엔딩 크레딧에 표시할 유저"
endingCreditMembersDescription: "서버 정보 페이지에 표시할 유저의 ID를 한 줄에 하나씩 적습니다." endingCreditMembersDescription: "서버 정보 페이지에 표시할 유저의 ID를 한 줄에 하나씩 적습니다."
emailAddressLogin: "이메일 주소로 로그인"
usernameLogin: "사용자명으로 로그인"
_bubbleGame: _bubbleGame:
howToPlay: "설명" howToPlay: "설명"
@ -2507,6 +2511,9 @@ _pages:
contentBlocks: "콘텐츠" contentBlocks: "콘텐츠"
inputBlocks: "입력" inputBlocks: "입력"
specialBlocks: "특수" specialBlocks: "특수"
visibility: "공개 범위"
public: "공개"
private: "비공개"
blocks: blocks:
text: "텍스트" text: "텍스트"
textarea: "텍스트 영역" textarea: "텍스트 영역"
@ -2825,3 +2832,9 @@ _hideSensitiveInformation:
roles: "역할" roles: "역할"
rolesUse: "할당된 역할 숨기기" rolesUse: "할당된 역할 숨기기"
rolesDescription: "이 옵션을 활성화하면 유저 프로필에서 모든 역할 목록이 표시되지 않게 됩니다." rolesDescription: "이 옵션을 활성화하면 유저 프로필에서 모든 역할 목록이 표시되지 않게 됩니다."
_selfXssPrevention:
warning: "경고"
title: "「이 화면에 무언가를 붙여넣으라는 메시지」는 모두 *사기*입니다."
description1: "여기에 무언가를 붙여넣으면, 악의를 가진 사용자에게 계정을 탈취당하거나 개인정보를 훔쳐갈 수 있습니다."
description2: "붙여넣으려는 것이 무엇인지 정확히 이해하지 못하면, %c지금 작업을 중단하고 이 창을 닫으십시오."
description3: "자세한 내용은 여기를 확인하십시오. {link}"

View file

@ -894,6 +894,7 @@ followersVisibility: "Widoczność obserwujących"
continueThread: "Pokaż kontynuację wątku" continueThread: "Pokaż kontynuację wątku"
deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?" deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?"
incorrectPassword: "Nieprawidłowe hasło." incorrectPassword: "Nieprawidłowe hasło."
authenticationFailed: "Uwierzytelnienie nie powiodło się."
voteConfirm: "Potwierdzić swój głos na \"{choice}\"?" voteConfirm: "Potwierdzić swój głos na \"{choice}\"?"
hide: "Ukryj" hide: "Ukryj"
useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych" useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych"

View file

@ -863,6 +863,7 @@ unmuteThread: "Desativar silêncio desta conversa"
continueThread: "Ver mais desta conversa" continueThread: "Ver mais desta conversa"
deleteAccountConfirm: "Deseja realmente excluir a conta?" deleteAccountConfirm: "Deseja realmente excluir a conta?"
incorrectPassword: "Senha inválida." incorrectPassword: "Senha inválida."
authenticationFailed: "Falha na autenticação."
voteConfirm: "Deseja confirmar o seu voto em \"{choice}\"?" voteConfirm: "Deseja confirmar o seu voto em \"{choice}\"?"
hide: "Ocultar" hide: "Ocultar"
useDrawerReactionPickerForMobile: "Mostrar em formato de gaveta" useDrawerReactionPickerForMobile: "Mostrar em formato de gaveta"

View file

@ -881,6 +881,7 @@ unmuteThread: "Отменить сокрытие цепочки"
continueThread: "Показать следующие ответы" continueThread: "Показать следующие ответы"
deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?"
incorrectPassword: "Пароль неверен." incorrectPassword: "Пароль неверен."
authenticationFailed: "Аутентификация не удалась."
voteConfirm: "Отдать голос за «{choice}»?" voteConfirm: "Отдать голос за «{choice}»?"
hide: "Спрятать" hide: "Спрятать"
useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве" useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве"

View file

@ -826,6 +826,7 @@ unmuteThread: "Zrušiť stíšenie vlákna"
continueThread: "Zobraziť pokračovanie vlákna" continueThread: "Zobraziť pokračovanie vlákna"
deleteAccountConfirm: "Toto nezvrátiteľne vymaže váš účet. Pokračovať?" deleteAccountConfirm: "Toto nezvrátiteľne vymaže váš účet. Pokračovať?"
incorrectPassword: "Nesprávne heslo." incorrectPassword: "Nesprávne heslo."
authenticationFailed: "Overenie zlyhalo."
voteConfirm: "Potvrdzujete svoj hlas za \"{choice}\"?" voteConfirm: "Potvrdzujete svoj hlas za \"{choice}\"?"
hide: "Skryť" hide: "Skryť"
useDrawerReactionPickerForMobile: "Zobraziť výber reakcií ako šuflík na mobile" useDrawerReactionPickerForMobile: "Zobraziť výber reakcií ako šuflík na mobile"

View file

@ -466,6 +466,7 @@ squareAvatars: "Visa fyrkantiga profilbilder"
sent: "Skicka" sent: "Skicka"
misskeyUpdated: "Misskey har uppdaterats!" misskeyUpdated: "Misskey har uppdaterats!"
incorrectPassword: "Fel lösenord." incorrectPassword: "Fel lösenord."
authenticationFailed: "Autentisering misslyckades."
welcomeBackWithName: "Välkommen tillbaka, {name}" welcomeBackWithName: "Välkommen tillbaka, {name}"
clickToFinishEmailVerification: "Tryck på [{ok}] för att slutföra bekräftelsen på e-postadressen." clickToFinishEmailVerification: "Tryck på [{ok}] för att slutföra bekräftelsen på e-postadressen."
searchByGoogle: "Sök" searchByGoogle: "Sök"

View file

@ -896,6 +896,7 @@ followersVisibility: "การมองเห็นผู้ที่กำล
continueThread: "ดูความต่อเนื่องเธรด" continueThread: "ดูความต่อเนื่องเธรด"
deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?" deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?"
incorrectPassword: "รหัสผ่านไม่ถูกต้อง" incorrectPassword: "รหัสผ่านไม่ถูกต้อง"
authenticationFailed: "การตรวจสอบตัวตนล้มเหลว"
voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?" voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?"
hide: "ซ่อน" hide: "ซ่อน"
useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ" useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ"

View file

@ -822,6 +822,7 @@ unmuteThread: "Скасувати глушіння"
continueThread: "Показати продовження треду" continueThread: "Показати продовження треду"
deleteAccountConfirm: "Це незворотно видалить ваш акаунт. Продовжити?" deleteAccountConfirm: "Це незворотно видалить ваш акаунт. Продовжити?"
incorrectPassword: "Неправильний пароль." incorrectPassword: "Неправильний пароль."
authenticationFailed: "Аутентифікація не вдалася."
voteConfirm: "Підтверджуєте свій голос за \"{choice}\"?" voteConfirm: "Підтверджуєте свій голос за \"{choice}\"?"
hide: "Сховати" hide: "Сховати"
welcomeBackWithName: "З поверненням, {name}!" welcomeBackWithName: "З поверненням, {name}!"

View file

@ -876,6 +876,7 @@ followersVisibility: "Hiển thị người theo dõi"
continueThread: "Tiếp tục xem chuỗi tút" continueThread: "Tiếp tục xem chuỗi tút"
deleteAccountConfirm: "Điều này sẽ khiến tài khoản bị xóa vĩnh viễn. Vẫn tiếp tục?" deleteAccountConfirm: "Điều này sẽ khiến tài khoản bị xóa vĩnh viễn. Vẫn tiếp tục?"
incorrectPassword: "Sai mật khẩu." incorrectPassword: "Sai mật khẩu."
authenticationFailed: "Xác thực thất bại."
voteConfirm: "Xác nhận bình chọn \"{choice}\"?" voteConfirm: "Xác nhận bình chọn \"{choice}\"?"
hide: "Ẩn" hide: "Ẩn"
useDrawerReactionPickerForMobile: "Hiện bộ chọn biểu cảm dạng xổ ra trên điện thoại" useDrawerReactionPickerForMobile: "Hiện bộ chọn biểu cảm dạng xổ ra trên điện thoại"

View file

@ -899,6 +899,7 @@ followersVisibility: "关注者的公开范围"
continueThread: "查看更多帖子" continueThread: "查看更多帖子"
deleteAccountConfirm: "将要删除账户。是否确认?" deleteAccountConfirm: "将要删除账户。是否确认?"
incorrectPassword: "密码错误" incorrectPassword: "密码错误"
authenticationFailed: "认证失败"
voteConfirm: "确定投给 “{choice}” " voteConfirm: "确定投给 “{choice}” "
hide: "隐藏" hide: "隐藏"
useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示" useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示"

View file

@ -896,6 +896,7 @@ followersVisibility: "追隨者的可見性"
continueThread: "查看更多貼文" continueThread: "查看更多貼文"
deleteAccountConfirm: "將要刪除帳戶。是否確定?" deleteAccountConfirm: "將要刪除帳戶。是否確定?"
incorrectPassword: "密碼錯誤。" incorrectPassword: "密碼錯誤。"
authenticationFailed: "驗證失敗。"
voteConfirm: "確定投給「{choice}」?" voteConfirm: "確定投給「{choice}」?"
hide: "隱藏" hide: "隱藏"
useDrawerReactionPickerForMobile: "在移動設備上使用抽屜顯示" useDrawerReactionPickerForMobile: "在移動設備上使用抽屜顯示"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.5.0-oscar.21c", "version": "2024.5.0-oscar.22",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -55,7 +55,6 @@
"esbuild": "0.24.2", "esbuild": "0.24.2",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
"lodash": "4.17.21", "lodash": "4.17.21",
"punycode": "npm:punycode.js@2.3.1",
"sharp": "0.33.5", "sharp": "0.33.5",
"tough-cookie": "5.0.0", "tough-cookie": "5.0.0",
"web-streams-polyfill": "4.0.0" "web-streams-polyfill": "4.0.0"

View file

@ -0,0 +1,13 @@
export class RoleAssignmentMemo1735078824104 {
name = 'RoleAssignmentMemo1735078824104'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "role_assignment" ADD "memo" character varying(256)`);
await queryRunner.query(`COMMENT ON COLUMN "role_assignment"."memo" IS 'memo for the role assignment'`);
}
async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "role_assignment"."memo" IS 'memo for the role assignment'`);
await queryRunner.query(`ALTER TABLE "role_assignment" DROP COLUMN "memo"`);
}
}

View file

@ -33,19 +33,19 @@
"generate-api-json": "pnpm build && node ./scripts/generate_api_json.js" "generate-api-json": "pnpm build && node ./scripts/generate_api_json.js"
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-darwin-arm64": "1.10.1", "@swc/core-darwin-arm64": "1.10.3",
"@swc/core-darwin-x64": "1.10.1", "@swc/core-darwin-x64": "1.10.3",
"@swc/core-linux-arm-gnueabihf": "1.10.1", "@swc/core-linux-arm-gnueabihf": "1.10.3",
"@swc/core-linux-arm64-gnu": "1.10.1", "@swc/core-linux-arm64-gnu": "1.10.3",
"@swc/core-linux-arm64-musl": "1.10.1", "@swc/core-linux-arm64-musl": "1.10.3",
"@swc/core-linux-x64-gnu": "1.10.1", "@swc/core-linux-x64-gnu": "1.10.3",
"@swc/core-linux-x64-musl": "1.10.1", "@swc/core-linux-x64-musl": "1.10.3",
"@swc/core-win32-arm64-msvc": "1.10.1", "@swc/core-win32-arm64-msvc": "1.10.3",
"@swc/core-win32-ia32-msvc": "1.10.1", "@swc/core-win32-ia32-msvc": "1.10.3",
"@swc/core-win32-x64-msvc": "1.10.1", "@swc/core-win32-x64-msvc": "1.10.3",
"@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0", "@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.8", "bufferutil": "4.0.9",
"slacc-android-arm-eabi": "0.0.10", "slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10", "slacc-android-arm64": "0.0.10",
"slacc-darwin-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10",
@ -65,9 +65,9 @@
"@authenio/samlify-node-xmllint": "2.0.0", "@authenio/samlify-node-xmllint": "2.0.0",
"@aws-sdk/client-s3": "3.717.0", "@aws-sdk/client-s3": "3.717.0",
"@aws-sdk/lib-storage": "3.717.0", "@aws-sdk/lib-storage": "3.717.0",
"@bull-board/api": "6.5.3", "@bull-board/api": "6.5.4",
"@bull-board/fastify": "6.5.3", "@bull-board/fastify": "6.5.4",
"@bull-board/ui": "6.5.3", "@bull-board/ui": "6.5.4",
"@discordapp/twemoji": "15.1.0", "@discordapp/twemoji": "15.1.0",
"@elastic/elasticsearch": "8.17.0", "@elastic/elasticsearch": "8.17.0",
"@fastify/accepts": "5.0.2", "@fastify/accepts": "5.0.2",
@ -88,9 +88,9 @@
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "13.0.0", "@simplewebauthn/server": "13.0.0",
"@sinonjs/fake-timers": "11.3.1", "@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "3.3.2", "@smithy/node-http-handler": "3.3.3",
"@swc/cli": "0.5.2", "@swc/cli": "0.5.2",
"@swc/core": "1.10.1", "@swc/core": "1.10.3",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.17.1", "ajv": "8.17.1",
@ -99,10 +99,10 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.3", "body-parser": "1.20.3",
"bullmq": "5.34.4", "bullmq": "5.34.5",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "10.0.3", "cbor": "10.0.3",
"chalk": "5.4.0", "chalk": "5.4.1",
"chalk-template": "1.1.0", "chalk-template": "1.1.0",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"cli-highlight": "2.1.11", "cli-highlight": "2.1.11",
@ -118,7 +118,6 @@
"fluent-ffmpeg": "2.1.3", "fluent-ffmpeg": "2.1.3",
"form-data": "4.0.1", "form-data": "4.0.1",
"got": "14.4.5", "got": "14.4.5",
"happy-dom": "15.11.7",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"htmlescape": "1.1.1", "htmlescape": "1.1.1",
"http-link-header": "1.1.3", "http-link-header": "1.1.3",
@ -130,9 +129,9 @@
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "25.0.1", "jsdom": "25.0.1",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.2", "jsonld": "8.3.3",
"jsrsasign": "11.1.0", "jsrsasign": "11.1.0",
"meilisearch": "0.46.0", "meilisearch": "0.47.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",
@ -152,11 +151,12 @@
"otpauth": "9.3.6", "otpauth": "9.3.6",
"parse5": "7.2.1", "parse5": "7.2.1",
"pg": "8.13.1", "pg": "8.13.1",
"pino": "9.5.0", "pino": "9.6.0",
"pino-pretty": "13.0.0", "pino-pretty": "13.0.0",
"pkce-challenge": "4.1.0", "pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",
"promise-limit": "2.7.0", "promise-limit": "2.7.0",
"psl": "1.15.0",
"pug": "3.0.3", "pug": "3.0.3",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@ -175,7 +175,7 @@
"slacc": "0.0.10", "slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"systeminformation": "5.23.14", "systeminformation": "5.23.23",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.3", "tmp": "0.2.3",
"tsc-alias": "1.8.10", "tsc-alias": "1.8.10",
@ -201,7 +201,7 @@
"@types/color-convert": "2.0.4", "@types/color-convert": "2.0.4",
"@types/content-disposition": "0.5.8", "@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.27", "@types/fluent-ffmpeg": "2.1.27",
"@types/htmlescape": "^1.1.3", "@types/htmlescape": "1.1.3",
"@types/http-link-header": "1.0.7", "@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
@ -217,6 +217,7 @@
"@types/oauth2orize": "1.11.5", "@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.10", "@types/pg": "8.11.10",
"@types/psl": "1.1.3",
"@types/pug": "2.0.10", "@types/pug": "2.0.10",
"@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
@ -239,7 +240,7 @@
"eslint": "8.57.1", "eslint": "8.57.1",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"execa": "9.5.2", "execa": "9.5.2",
"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",
"nodemon": "3.1.9", "nodemon": "3.1.9",

View file

@ -17,6 +17,7 @@ export type RedisOptionsSource = Partial<RedisOptions> & {
pass: string; pass: string;
db?: number; db?: number;
prefix?: string; prefix?: string;
queueNameSuffix?: string;
}; };
/** /**

View file

@ -58,11 +58,11 @@ export class AiService {
const sharp = await sharpBmp(path, mime); const sharp = await sharpBmp(path, mime);
const { data, info } = await sharp const { data, info } = await sharp
.resize(299, 299, { fit: 'inside' }) .resize(299, 299, { fit: 'inside' })
.ensureAlpha() .removeAlpha()
.raw({ depth: 'int' }) .raw({ depth: 'uchar' })
.toBuffer({ resolveWithObject: true }); .toBuffer({ resolveWithObject: true });
const image = tf.tensor3d(data, [info.height, info.width, info.channels], 'int32'); const image = tf.tensor3d(data, [info.height, info.width, info.channels], 'bool');
try { try {
return await this.model.classify(image); return await this.model.classify(image);
} finally { } finally {

View file

@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }

View file

@ -58,10 +58,10 @@ export class CacheService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new MemoryKVCache<MiUser>(Infinity); this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity); this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity); this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity); this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', { this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@ -153,14 +153,14 @@ export class CacheService implements OnApplicationShutdown {
if (user == null) { if (user == null) {
this.userByIdCache.delete(body.id); this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id); this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.entries) {
if (v.value?.id === body.id) { if (v.value?.id === body.id) {
this.uriPersonCache.delete(k); this.uriPersonCache.delete(k);
} }
} }
} else { } else {
this.userByIdCache.set(user.id, user); this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) { for (const [k, v] of this.uriPersonCache.entries) {
if (v.value?.id === user.id) { if (v.value?.id === user.id) {
this.uriPersonCache.set(k, user); this.uriPersonCache.set(k, user);
} }

View file

@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
@Injectable() @Injectable()
export class CustomEmojiService implements OnApplicationShutdown { export class CustomEmojiService implements OnApplicationShutdown {
private cache: MemoryKVCache<MiEmoji | null>; private emojisCache: MemoryKVCache<MiEmoji | null>;
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>; public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;
constructor( constructor(
@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h
this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', { this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
@ -311,7 +311,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定 : this.utilityService.isSelfHost(src) ? null // 自ホスト指定
: (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
host = this.utilityService.toPunyNullable(host); host = host ? this.utilityService.normalizeHost(host) : null;
return host; return host;
} }
@ -324,7 +324,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
const name = match[1]; const name = match[1];
// ホスト正規化 // ホスト正規化
const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost)); const host = this.normalizeHost(match[2], noteUserHost);
return { name, host }; return { name, host };
} }
@ -346,7 +346,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
host, host,
})) ?? null; })) ?? null;
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull);
if (emoji == null) return null; if (emoji == null) return null;
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
@ -372,7 +372,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
*/ */
@bindThis @bindThis
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null);
const emojisQuery: any[] = []; const emojisQuery: any[] = [];
const hosts = new Set(notCachedEmojis.map(e => e.host)); const hosts = new Set(notCachedEmojis.map(e => e.host));
for (const host of hosts) { for (const host of hosts) {
@ -387,7 +387,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
select: ['name', 'host', 'originalUrl', 'publicUrl'], select: ['name', 'host', 'originalUrl', 'publicUrl'],
}) : []; }) : [];
for (const emoji of _emojis) { for (const emoji of _emojis) {
this.cache.set(`${emoji.name} ${emoji.host}`, emoji); this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji);
} }
} }
@ -412,7 +412,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.emojisCache.dispose();
} }
@bindThis @bindThis

View file

@ -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';
@ -70,13 +69,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 +131,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';
}
} }

View file

@ -236,7 +236,7 @@ export class EmailService {
} }
const emailDomain: string = emailAddress.split('@')[1]; const emailDomain: string = emailAddress.split('@')[1];
const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain); const isBanned = this.utilityService.isItemListedIn(emailDomain, meta.bannedEmailDomains);
if (isBanned) { if (isBanned) {
return { return {
@ -304,7 +304,7 @@ export class EmailService {
reason: 'mx', reason: 'mx',
}; };
} }
if (json.mx_host?.some(host => this.utilityService.isBlockedHost(meta.bannedEmailDomains, host))) { if (json.mx_host?.some(host => this.utilityService.isItemListedIn(host, meta.bannedEmailDomains))) {
return { return {
valid: false, valid: false,
reason: 'mx', reason: 'mx',
@ -330,6 +330,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 {

View file

@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
@bindThis @bindThis
public async fetch(host: string): Promise<MiInstance> { public async fetch(host: string): Promise<MiInstance> {
host = this.utilityService.toPuny(host); host = this.utilityService.normalizeHost(host);
const cached = await this.federatedInstanceCache.get(host); const cached = await this.federatedInstanceCache.get(host);
if (cached) return cached; if (cached) return cached;

View file

@ -6,12 +6,14 @@
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 { HttpProxyAgent, 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 { UtilityService } from '@/core/UtilityService.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';
@ -24,8 +26,102 @@ export type HttpRequestSendOptions = {
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 (without local address filtering)
*/
private httpNative: http.Agent;
/**
* Get https non-proxy agent (without local address filtering)
*/
private httpsNative: https.Agent;
/** /**
* Get http non-proxy agent * Get http non-proxy agent
*/ */
@ -49,6 +145,8 @@ export class HttpRequestService {
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
private utilityService: UtilityService,
) { ) {
const cache = new CacheableLookup({ const cache = new CacheableLookup({
maxTtl: 3600, // 1hours maxTtl: 3600, // 1hours
@ -56,19 +154,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,
}); };
this.https = new https.Agent({ this.httpNative = new http.Agent(agentOption);
keepAlive: true,
keepAliveMsecs: 30 * 1000, this.httpsNative = new https.Agent(agentOption);
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress, this.http = new HttpRequestServiceAgent(config, agentOption);
});
this.https = new HttpsRequestServiceAgent(config, agentOption);
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
@ -103,16 +202,22 @@ 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, isLocalAddressAllowed = false): http.Agent | https.Agent {
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) { if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
if (isLocalAddressAllowed) {
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
}
return url.protocol === 'http:' ? this.http : this.https; return url.protocol === 'http:' ? this.http : this.https;
} else { } else {
if (isLocalAddressAllowed && (!this.config.proxy)) {
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
}
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; return url.protocol === 'http:' ? this.httpAgent : 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 +225,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;
this.utilityService.assertActivityRelatedToUrl(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 +248,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 +277,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,
@ -178,6 +292,7 @@ export class HttpRequestService {
}, timeout); }, timeout);
const bearcaps = url.startsWith('bear:?') ? this.parseBearcaps(url) : undefined; const bearcaps = url.startsWith('bear:?') ? this.parseBearcaps(url) : undefined;
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
const res = await fetch(bearcaps?.url ?? url, { const res = await fetch(bearcaps?.url ?? url, {
method: args.method ?? 'GET', method: args.method ?? 'GET',
@ -188,7 +303,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, isLocalAddressAllowed),
signal: controller.signal, signal: controller.signal,
}); });

View file

@ -298,7 +298,7 @@ export class NoteCreateService implements OnApplicationShutdown {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Notes including prohibited words are not allowed.'); throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Notes including prohibited words are not allowed.');
} }
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); const inSilencedInstance = this.utilityService.isItemListedIn(user.host, meta.silencedHosts);
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {
data.visibility = 'home'; data.visibility = 'home';

View file

@ -6,8 +6,8 @@
import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import * as Bull from 'bullmq'; import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config, RedisOptionsSource } from '@/config.js';
import { QUEUE, baseQueueOptions } from '@/queue/const.js'; import { QUEUE, baseQueueOptions, formatQueueName } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js'; import { allSettled } from '@/misc/promise-tracker.js';
import { Queues } from '@/misc/queues.js'; import { Queues } from '@/misc/queues.js';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -36,13 +36,13 @@ const $endedPollNotification: Provider = {
const $deliver: Provider = { const $deliver: Provider = {
provide: 'queue:deliver', provide: 'queue:deliver',
useFactory: (config: Config) => new Queues(config.redisForDeliverQueues.map(queueConfig => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(queueConfig, config.bullmqQueueOptions, QUEUE.DELIVER)))), useFactory: (config: Config) => createQueues(QUEUE.DELIVER, config.redisForDeliverQueues, config.bullmqQueueOptions),
inject: [DI.config], inject: [DI.config],
}; };
const $inbox: Provider = { const $inbox: Provider = {
provide: 'queue:inbox', provide: 'queue:inbox',
useFactory: (config: Config) => new Queues(config.redisForInboxQueues.map(queueConfig => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(queueConfig, config.bullmqQueueOptions, QUEUE.INBOX)))), useFactory: (config: Config) => createQueues(QUEUE.INBOX, config.redisForInboxQueues, config.bullmqQueueOptions),
inject: [DI.config], inject: [DI.config],
}; };
@ -54,7 +54,7 @@ const $db: Provider = {
const $relationship: Provider = { const $relationship: Provider = {
provide: 'queue:relationship', provide: 'queue:relationship',
useFactory: (config: Config) => new Queues(config.redisForRelationshipQueues.map(queueConfig => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(queueConfig, config.bullmqQueueOptions, QUEUE.RELATIONSHIP)))), useFactory: (config: Config) => createQueues(QUEUE.RELATIONSHIP, config.redisForRelationshipQueues, config.bullmqQueueOptions),
inject: [DI.config], inject: [DI.config],
}; };
@ -70,6 +70,10 @@ const $webhookDeliver: Provider = {
inject: [DI.config], inject: [DI.config],
}; };
function createQueues(name: typeof QUEUE[keyof typeof QUEUE], config: RedisOptionsSource[], queueOptions: Partial<Bull.QueueOptions>): Queues {
return new Queues(config.map(queueConfig => new Bull.Queue(formatQueueName(queueConfig, name), baseQueueOptions(queueConfig, queueOptions, name))));
}
@Module({ @Module({
imports: [ imports: [
], ],

View file

@ -129,7 +129,7 @@ export class ReactionService {
} else if (_reaction) { } else if (_reaction) {
const custom = reaction.match(isCustomEmojiRegexp); const custom = reaction.match(isCustomEmojiRegexp);
if (custom) { if (custom) {
const reacterHost = this.utilityService.toPunyNullable(user.host); const reacterHost = user.host ? this.utilityService.normalizeHost(user.host) : null;
const name = custom[1]; const name = custom[1];
const emoji = reacterHost == null const emoji = reacterHost == null

View file

@ -35,7 +35,7 @@ export class RelayService {
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
) { ) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m
} }
@bindThis @bindThis

View file

@ -54,9 +54,7 @@ export class RemoteUserResolveService {
}) as MiLocalUser; }) as MiLocalUser;
} }
host = this.utilityService.toPuny(host); if (this.utilityService.isSelfHost(host)) {
if (this.config.host === host) {
this.logger.info(`return local user: ${usernameLower}`); this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
if (u == null) { if (u == null) {
@ -67,6 +65,8 @@ export class RemoteUserResolveService {
}) as MiLocalUser; }) as MiLocalUser;
} }
host = this.utilityService.normalizeHost(host);
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null; const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null;
const acctLower = `${usernameLower}@${host}`; const acctLower = `${usernameLower}@${host}`;

View file

@ -155,10 +155,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
//this.onMessage = this.onMessage.bind(this); this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
} }
@ -503,7 +501,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async assign(userId: MiUser['id'], roleId: MiRole['id'], expiresAt: Date | null = null, moderator?: MiUser): Promise<void> { public async assign(userId: MiUser['id'], roleId: MiRole['id'], memo: string | null = null, expiresAt: Date | null = null, moderator?: MiUser): Promise<void> {
const now = Date.now(); const now = Date.now();
const role = await this.rolesRepository.findOneByOrFail({ id: roleId }); const role = await this.rolesRepository.findOneByOrFail({ id: roleId });
@ -527,6 +525,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
expiresAt: expiresAt, expiresAt: expiresAt,
roleId: roleId, roleId: roleId,
userId: userId, userId: userId,
memo: memo,
}).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
this.globalEventService.publishInternalEvent('userRoleAssigned', created); this.globalEventService.publishInternalEvent('userRoleAssigned', created);
@ -536,9 +535,10 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
roleId: roleId, roleId: roleId,
}); });
} }
} else if (existing.expiresAt !== expiresAt) { } else if (existing.expiresAt !== expiresAt || existing.memo !== memo) {
await this.roleAssignmentsRepository.update(existing.id, { await this.roleAssignmentsRepository.update(existing.id, {
expiresAt: expiresAt, expiresAt: expiresAt,
memo: memo,
}); });
} else { } else {
throw new IdentifiableError('67d8689c-25c6-435f-8ced-631e4b81fce1', 'User is already assigned to this role.'); throw new IdentifiableError('67d8689c-25c6-435f-8ced-631e4b81fce1', 'User is already assigned to this role.');
@ -557,6 +557,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
userUsername: user.username, userUsername: user.username,
userHost: user.host, userHost: user.host,
expiresAt: expiresAt ? expiresAt.toISOString() : null, expiresAt: expiresAt ? expiresAt.toISOString() : null,
memo: memo,
}); });
} }
} }
@ -597,6 +598,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
userId: userId, userId: userId,
userUsername: user.username, userUsername: user.username,
userHost: user.host, userHost: user.host,
memo: existing.memo,
}); });
} }
} }

View file

@ -142,7 +142,7 @@ export class SignupService {
id: this.idService.gen(), id: this.idService.gen(),
username: username, username: username,
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: this.utilityService.toPunyNullable(host), host: host ? this.utilityService.normalizeHost(host) : null,
token: secret, token: secret,
autoRemoval: false, autoRemoval: false,
isRoot: isTheFirstUser, isRoot: isTheFirstUser,

View file

@ -173,7 +173,7 @@ export class UserFollowingService implements OnModuleInit {
followee.isLocked || followee.isLocked ||
(followeeProfile.carefulBot && follower.isBot) || (followeeProfile.carefulBot && follower.isBot) ||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') ||
(this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost((await this.metaService.fetch()).silencedHosts, follower.host)) (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isItemListedIn(follower.host, (await this.metaService.fetch()).silencedHosts))
) { ) {
let autoAccept = false; let autoAccept = false;

View file

@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown {
) { ) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: Infinity, memoryCacheLifetime: 1000 * 60 * 60, // 1h
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),

View file

@ -4,12 +4,15 @@
*/ */
import { URL } from 'node:url'; import { URL } from 'node:url';
import { isIP } from 'node:net';
import punycode from 'punycode.js'; import punycode from 'punycode.js';
import { Inject, Injectable } from '@nestjs/common'; import psl from 'psl';
import RE2 from 're2'; import RE2 from 're2';
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 { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { IObject } from '@/core/activitypub/type.js';
@Injectable() @Injectable()
export class UtilityService { export class UtilityService {
@ -21,31 +24,26 @@ export class UtilityService {
@bindThis @bindThis
public getFullApAccount(username: string, host: string | null): string { public getFullApAccount(username: string, host: string | null): string {
return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`; return host ? `${username}@${this.normalizeHost(host)}` : `${username}@${this.normalizeHost(this.config.host)}`;
} }
@bindThis @bindThis
public isSelfHost(host: string | null): boolean { public isSelfHost(host: string | null): boolean {
if (host == null) return true; if (host == null) return true;
return this.toPuny(this.config.host) === this.toPuny(host); return this.normalizeHost(this.config.host) === this.normalizeHost(host);
} }
@bindThis @bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean { public isUriLocal(uri: string): boolean {
if (host == null) return false; return this.normalizeHost(this.config.host) === this.extractHost(uri);
return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
@bindThis @bindThis
public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { public isItemListedIn(item: string | null, list: string[] | undefined): boolean {
if (!silencedHosts || host == null) return false; if (!list || !item) return false;
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); list = list.map(x => '.' + this.normalizeHost(x).split(':')[0]);
} item = '.' + this.normalizeHost(item).split(':')[0];
return list.some(x => item.endsWith(x));
@bindThis
public isSensitiveMediaHost(sensitiveMediaHosts: string[] | undefined, host: string | null): boolean {
if (!sensitiveMediaHosts || host == null) return false;
return sensitiveMediaHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
@bindThis @bindThis
@ -88,19 +86,112 @@ export class UtilityService {
} }
@bindThis @bindThis
public extractDbHost(uri: string): string { public normalizeHost(host: string): string {
const url = new URL(uri);
return this.toPuny(url.hostname);
}
@bindThis
public toPuny(host: string): string {
return punycode.toASCII(host.toLowerCase()); return punycode.toASCII(host.toLowerCase());
} }
@bindThis @bindThis
public toPunyNullable(host: string | null | undefined): string | null { public extractHost(uri: string): string {
if (host == null) return null; // ASCII String で返されるので punycode 化はいらない
return punycode.toASCII(host.toLowerCase()); // ref: https://url.spec.whatwg.org/#host-serializing
return new URL(uri).host;
}
@bindThis
public isRelatedHosts(hostA: string, hostB: string): boolean {
// hostA と hostB は呼び出す側で正規化済みであることを前提とする
// ポート番号が付いている可能性がある場合、ポート番号を除去するためにもう一度正規化
if (hostA.includes(':')) hostA = new URL(`urn://${hostA}`).hostname;
if (hostB.includes(':')) hostB = new URL(`urn://${hostB}`).hostname;
// ホストが完全一致している場合は true
if (hostA === hostB) {
return true;
}
// -----------------------------
// 1. IPアドレスの場合の処理
// -----------------------------
const aIpVersion = isIP(hostA);
const bIpVersion = isIP(hostB);
if (aIpVersion !== 0 || bIpVersion !== 0) {
// どちらかが IP の場合、完全一致以外は false
return false;
}
// -----------------------------
// 2. ホストの場合の処理
// -----------------------------
const parsedA = psl.parse(hostA);
const parsedB = psl.parse(hostB);
// どちらか一方でもパース失敗 or eTLD+1が異なる場合は false
if (parsedA.error || parsedB.error || parsedA.domain !== parsedB.domain) {
return false;
}
// -----------------------------
// 3. サブドメインの比較
// -----------------------------
// サブドメイン部分が後方一致で階層差が1以内かどうかを判定する。
// 完全一致だと既に true で返しているので、ここでは完全一致以外の場合のみの判定
// 例:
// subA = "www", subB = "" => true (1階層差)
// subA = "alice.users", subB = "users" => true (1階層差)
// subA = "alice.users", subB = "bob.users" => true (1階層差)
// subA = "alice.users", subB = "" => false (2階層差)
const labelsA = parsedA.subdomain?.split('.') ?? [];
const levelsA = labelsA.length;
const labelsB = parsedB.subdomain?.split('.') ?? [];
const levelsB = labelsB.length;
// 後ろ(右)から一致している部分をカウント
let i = 0;
while (
i < levelsA &&
i < levelsB &&
labelsA[levelsA - 1 - i] === labelsB[levelsB - 1 - i]
) {
i++;
}
// 後方一致していないラベルの数 = (総数 - 一致数)
const unmatchedA = levelsA - i;
const unmatchedB = levelsB - i;
// 不一致ラベルが1階層以内なら true
return Math.max(unmatchedA, unmatchedB) <= 1;
}
@bindThis
public isRelatedUris(uriA: string, uriB: string): boolean {
// URI が完全一致している場合は true
if (uriA === uriB) {
return true;
}
const hostA = this.extractHost(uriA);
const hostB = this.extractHost(uriB);
return this.isRelatedHosts(hostA, hostB);
}
@bindThis
public assertActivityRelatedToUrl(activity: IObject, url: string): void {
if (activity.id && this.isRelatedUris(activity.id, url)) return;
if (activity.url) {
if (!Array.isArray(activity.url)) {
if (typeof(activity.url) === 'string' && this.isRelatedUris(activity.url, url)) return;
if (typeof(activity.url) === 'object' && activity.url.href && this.isRelatedUris(activity.url.href, url)) return;
} else {
if (activity.url.some(x => typeof(x) === 'string' && this.isRelatedUris(x, url))) return;
if (activity.url.some(x => typeof(x) === 'object' && x.href && this.isRelatedUris(x.href, url))) return;
}
}
throw new Error(`Invalid object: neither id(${activity.id}) nor url(${activity.url}) related to ${url}`);
} }
} }

View file

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js'; import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
@ -53,17 +54,21 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
private utilityService: UtilityService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
} }
@bindThis @bindThis
public parseUri(value: string | IObject): UriParseResult { public parseUri(value: string | IObject): UriParseResult {
const separator = '/'; const separator = '/';
const uri = new URL(getApId(value)); const apId = getApId(value);
if (uri.origin !== this.config.url) return { local: false, uri: uri.href }; const uri = new URL(apId);
if (!this.utilityService.isUriLocal(apId)) {
return { local: false, uri: uri.href };
}
const [, type, id, ...rest] = uri.pathname.split(separator); const [, type, id, ...rest] = uri.pathname.split(separator);
return { return {

View file

@ -29,6 +29,7 @@ import { bindThis } from '@/decorators.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { isNotNull } from '@/misc/is-not-null.js'; import { isNotNull } from '@/misc/is-not-null.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdentifiableError } from '@/misc/identifiable-error.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, 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 { ApNoteService } from './models/ApNoteService.js'; import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js'; import { ApLoggerService } from './ApLoggerService.js';
@ -38,7 +39,7 @@ import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js'; 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 } from './type.js'; import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
@Injectable() @Injectable()
export class ApInboxService { export class ApInboxService {
@ -90,13 +91,26 @@ export class ApInboxService {
} }
@bindThis @bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject, additionalCc?: MiLocalUser['id']): Promise<void> { public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise<string> {
let result = 'error';
if (isCollectionOrOrderedCollection(activity)) { if (isCollectionOrOrderedCollection(activity)) {
const resolver = this.apResolverService.createResolver(); const results = [] as [string, string | void][];
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
if (items.length >= resolver.getRecursionLimit()) {
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractHost(actor.uri)}`);
}
for (const item of items) {
const act = await resolver.resolve(item); const act = await resolver.resolve(item);
if (act.id == null || this.utilityService.extractHost(act.id) !== this.utilityService.extractHost(actor.uri)) {
this.logger.warn('skipping activity: activity id is null or mismatching');
continue;
}
try { try {
await this.performOneActivity(actor, act, additionalCc); results.push([getApId(item), await this.performOneActivity(actor, act, resolver, additionalCc)]);
} catch (err) { } catch (err) {
if (err instanceof Error || typeof err === 'string') { if (err instanceof Error || typeof err === 'string') {
this.logger.error(err); this.logger.error(err);
@ -105,54 +119,62 @@ export class ApInboxService {
} }
} }
} }
const hasReason = results.some(([, reason]) => (reason != null && !reason.startsWith('ok')));
if (hasReason) {
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
}
} else { } else {
await this.performOneActivity(actor, activity, additionalCc); result = await this.performOneActivity(actor, activity, resolver, additionalCc);
} }
// ついでにリモートユーザーの情報が古かったら更新しておく // ついでにリモートユーザーの情報が古かったら更新しておく
if (actor.uri) { if (actor.uri) {
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => { setImmediate(() => {
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
this.apPersonService.updatePerson(actor.uri); this.apPersonService.updatePerson(actor.uri);
}); });
} }
} }
return result;
} }
@bindThis @bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject, additionalCc?: MiLocalUser['id']): Promise<void> { public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise<string> {
if (actor.isSuspended) return; if (actor.isSuspended) return 'skip: actor is suspended';
if (isCreate(activity)) { if (isCreate(activity)) {
await this.create(actor, activity, additionalCc); return await this.create(actor, activity, resolver, additionalCc);
} else if (isDelete(activity)) { } else if (isDelete(activity)) {
await this.delete(actor, activity); return await this.delete(actor, activity);
} else if (isUpdate(activity)) { } else if (isUpdate(activity)) {
await this.update(actor, activity, additionalCc); return await this.update(actor, activity, resolver, additionalCc);
} else if (isFollow(activity)) { } else if (isFollow(activity)) {
await this.follow(actor, activity); return await this.follow(actor, activity);
} else if (isAccept(activity)) { } else if (isAccept(activity)) {
await this.accept(actor, activity); return await this.accept(actor, activity, resolver);
} else if (isReject(activity)) { } else if (isReject(activity)) {
await this.reject(actor, activity); return await this.reject(actor, activity, resolver);
} else if (isAdd(activity)) { } else if (isAdd(activity)) {
await this.add(actor, activity).catch(err => this.logger.error(err)); return await this.add(actor, activity, resolver).catch(err => { this.logger.error(err); return `error: ${err.message}`; });
} else if (isRemove(activity)) { } else if (isRemove(activity)) {
await this.remove(actor, activity).catch(err => this.logger.error(err)); return await this.remove(actor, activity, resolver).catch(err => { this.logger.error(err); return `error: ${err.message}`; });
} else if (isAnnounce(activity)) { } else if (isAnnounce(activity)) {
await this.announce(actor, activity); return await this.announce(actor, activity, resolver);
} else if (isLike(activity)) { } else if (isLike(activity)) {
await this.like(actor, activity); return await this.like(actor, activity);
} else if (isUndo(activity)) { } else if (isUndo(activity)) {
await this.undo(actor, activity); return await this.undo(actor, activity, resolver);
} else if (isBlock(activity)) { } else if (isBlock(activity)) {
await this.block(actor, activity); return await this.block(actor, activity);
} else if (isFlag(activity)) { } else if (isFlag(activity)) {
await this.flag(actor, activity); return await this.flag(actor, activity);
} else if (isMove(activity)) { } else if (isMove(activity)) {
await this.move(actor, activity); return await this.move(actor, activity, resolver);
} else { } else {
this.logger.warn(`unrecognized activity type: ${activity.type}`); return `skip: unknown activity type ${activity.type}`;
} }
} }
@ -182,22 +204,26 @@ export class ApInboxService {
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => { try {
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name);
return 'ok';
} catch (err) {
if (err instanceof IdentifiableError && err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
return 'skip: already reacted'; return 'skip: already reacted';
} else { } else {
throw err; throw err;
} }
}).then(() => 'ok'); }
} }
@bindThis @bindThis
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> { private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
const uri = activity.id ?? activity; const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`); this.logger.info(`Accept: ${uri}`);
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => { const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`, { error: err }); this.logger.error(`Resolution failed: ${err}`, { error: err });
@ -206,7 +232,7 @@ export class ApInboxService {
if (isFollow(object)) return await this.acceptFollow(actor, object); if (isFollow(object)) return await this.acceptFollow(actor, object);
return `skip: Unknown Accept type: ${getApType(object)}`; return `skip: Unknown Accept type: ${getApType(object) ?? 'undefined'}}`;
} }
@bindThis @bindThis
@ -234,47 +260,57 @@ export class ApInboxService {
} }
@bindThis @bindThis
private async add(actor: MiRemoteUser, activity: IAdd): Promise<void> { private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string> {
if (actor.uri !== activity.actor) { if (actor.uri !== activity.actor) {
throw new Error('invalid actor'); return 'skip: invalid actor';
} }
if (activity.target == null) { if (activity.target == null) {
throw new Error('target is null'); return 'skip: target is null';
} }
if (activity.target === actor.featured) { if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object); const note = await this.apNoteService.resolveNote(activity.object, { resolver });
if (note == null) throw new Error('note not found'); if (note == null) return 'skip: note not found';
await this.notePiningService.addPinned(actor, note.id); await this.notePiningService.addPinned(actor, note.id);
return; return 'ok';
} }
throw new Error(`unknown target: ${activity.target}`); return `skip: unknown target ${activity.target}`;
} }
@bindThis @bindThis
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<void> { private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string> {
const uri = getApId(activity); const uri = getApId(activity);
this.logger.info(`Announce: ${uri}`); this.logger.info(`Announce: ${uri}`);
const targetUri = getApId(activity.object); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
await this.announceNote(actor, activity, targetUri); if (!activity.object) return 'skip: activity has no object property';
const target = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`, { error: err });
return err;
});
if (isPost(target)) await this.announceNote(actor, activity, target);
return `skip: unknown object type ${getApType(target) ?? 'undefined'}}`;
} }
@bindThis @bindThis
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> { private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string> {
const uri = getApId(activity); const uri = getApId(activity);
if (actor.isSuspended) { if (actor.isSuspended) {
return; return 'skip: actor is suspended';
} }
// アナウンス先をブロックしてたら中断 // アナウンス先をブロックしてたら中断
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return; if (this.utilityService.isItemListedIn(this.utilityService.extractHost(uri), meta.blockedHosts)) return 'skip: blocked host';
const unlock = await this.appLockService.getApLock(uri); const unlock = await this.appLockService.getApLock(uri);
@ -282,40 +318,37 @@ export class ApInboxService {
// 既に同じURIを持つものが登録されていないかチェック // 既に同じURIを持つものが登録されていないかチェック
const exist = await this.apNoteService.fetchNote(uri); const exist = await this.apNoteService.fetchNote(uri);
if (exist) { if (exist) {
return; return 'skip: note exists';
} }
// Announce対象をresolve // Announce対象をresolve
let renote; let renote;
try { try {
renote = await this.apNoteService.resolveNote(targetUri); renote = await this.apNoteService.resolveNote(target, { resolver });
if (renote == null) throw new Error('announce target is null'); if (renote == null) return 'skip: target note not found';
} catch (err) { } catch (err) {
// 対象が4xxならスキップ // 対象が4xxならスキップ
if (err instanceof StatusError) { if (err instanceof StatusError) {
if (!err.isRetryable) { if (!err.isRetryable) {
this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); return `skip: Ignored announce target ${target} - ${err.statusCode}`;
return;
} }
this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`); this.logger.warn(`Error in announce target ${target} - ${err.statusCode}`);
} }
throw err; throw err;
} }
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) {
this.logger.warn('skip: invalid actor for this activity'); return 'skip: invalid actor for this activity';
return;
} }
this.logger.info(`Creating the (Re)Note: ${uri}`); this.logger.info(`Creating the (Re)Note: ${uri}`);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
const createdAt = activity.published ? new Date(activity.published) : null; const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) { if (createdAt && createdAt < this.idService.parse(renote.id).date) {
this.logger.warn('skip: malformed createdAt'); return 'skip: malformed createdAt';
return;
} }
await this.noteCreateService.create(actor, { await this.noteCreateService.create(actor, {
@ -328,6 +361,8 @@ export class ApInboxService {
} finally { } finally {
unlock(); unlock();
} }
return 'ok';
} }
@bindThis @bindThis
@ -349,11 +384,13 @@ export class ApInboxService {
} }
@bindThis @bindThis
private async create(actor: MiRemoteUser, activity: ICreate, additionalCc?: MiLocalUser['id']): Promise<void> { private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise<string> {
const uri = getApId(activity); const uri = getApId(activity);
this.logger.info(`Create: ${uri}`); this.logger.info(`Create: ${uri}`);
if (!activity.object) return 'skip: activity has no object property';
// copy audiences between activity <=> object. // copy audiences between activity <=> object.
if (typeof activity.object === 'object') { if (typeof activity.object === 'object') {
const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
@ -370,7 +407,8 @@ export class ApInboxService {
activity.object.attributedTo = activity.actor; activity.object.attributedTo = activity.actor;
} }
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`, { error: e }); this.logger.error(`Resolution failed: ${e}`, { error: e });
@ -378,9 +416,9 @@ export class ApInboxService {
}); });
if (isPost(object)) { if (isPost(object)) {
await this.createNote(resolver, actor, object, false, activity, additionalCc); return await this.createNote(resolver, actor, object, false, activity, additionalCc);
} else { } else {
this.logger.warn(`Unknown type: ${getApType(object)}`); return `skip: Unknown type ${getApType(object) ?? 'undefined'}}`;
} }
} }
@ -394,9 +432,11 @@ export class ApInboxService {
} }
if (typeof note.id === 'string') { if (typeof note.id === 'string') {
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { if (this.utilityService.extractHost(actor.uri) !== this.utilityService.extractHost(note.id)) {
return 'skip: host in actor.uri !== note.id'; return 'skip: host in actor.uri !== note.id';
} }
} else {
return 'skip: note.id is not a string';
} }
} }
@ -412,7 +452,7 @@ export class ApInboxService {
return 'skip: note exists'; return 'skip: note exists';
} }
const createdNote = await this.apNoteService.createNote(note, resolver, silent); const createdNote = await this.apNoteService.createNote(note, actor, resolver, silent);
if (createdNote && additionalCc && !await this.noteEntityService.isVisibleForMe(createdNote, additionalCc)) { if (createdNote && additionalCc && !await this.noteEntityService.isVisibleForMe(createdNote, additionalCc)) {
await this.noteCreateService.appendNoteVisibleUser(actor, createdNote, additionalCc); await this.noteCreateService.appendNoteVisibleUser(actor, createdNote, additionalCc);
return 'ok: note visible user appended'; return 'ok: note visible user appended';
@ -433,7 +473,7 @@ export class ApInboxService {
@bindThis @bindThis
private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> { private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> {
if (actor.uri !== activity.actor) { if (actor.uri !== activity.actor) {
throw new Error('invalid actor'); return 'skip: invalid actor';
} }
// 削除対象objectのtype // 削除対象objectのtype
@ -553,12 +593,13 @@ export class ApInboxService {
} }
@bindThis @bindThis
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> { private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
const uri = activity.id ?? activity; const uri = activity.id ?? activity;
this.logger.info(`Reject: ${uri}`); this.logger.info(`Reject: ${uri}`);
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`, { error: e }); this.logger.error(`Resolution failed: ${e}`, { error: e });
@ -567,7 +608,7 @@ export class ApInboxService {
if (isFollow(object)) return await this.rejectFollow(actor, object); if (isFollow(object)) return await this.rejectFollow(actor, object);
return `skip: Unknown Reject type: ${getApType(object)}`; return `skip: Unknown Reject type: ${getApType(object) ?? 'undefined'}}`;
} }
@bindThis @bindThis
@ -595,36 +636,37 @@ export class ApInboxService {
} }
@bindThis @bindThis
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<void> { private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string> {
if (actor.uri !== activity.actor) { if (actor.uri !== activity.actor) {
throw new Error('invalid actor'); return 'skip: invalid actor';
} }
if (activity.target == null) { if (activity.target == null) {
throw new Error('target is null'); return 'skip: target is null';
} }
if (activity.target === actor.featured) { if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object); const note = await this.apNoteService.resolveNote(activity.object, { resolver });
if (note == null) throw new Error('note not found'); if (note == null) return 'skip: note not found';
await this.notePiningService.removePinned(actor, note.id); await this.notePiningService.removePinned(actor, note.id);
return; return 'ok';
} }
throw new Error(`unknown target: ${activity.target}`); return `skip: unknown target ${activity.target}`;
} }
@bindThis @bindThis
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> { private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
if (actor.uri !== activity.actor) { if (actor.uri !== activity.actor) {
throw new Error('invalid actor'); return 'skip: invalid actor';
} }
const uri = activity.id ?? activity; const uri = activity.id ?? activity;
this.logger.info(`Undo: ${uri}`); this.logger.info(`Undo: ${uri}`);
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`, { error: e }); this.logger.error(`Resolution failed: ${e}`, { error: e });
@ -638,7 +680,7 @@ export class ApInboxService {
if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
if (isAccept(object)) return await this.undoAccept(actor, object); if (isAccept(object)) return await this.undoAccept(actor, object);
return `skip: unknown object type ${getApType(object)}`; return `skip: unknown object type ${getApType(object) ?? 'undefined'}}`;
} }
@bindThis @bindThis
@ -748,14 +790,15 @@ export class ApInboxService {
} }
@bindThis @bindThis
private async update(actor: MiRemoteUser, activity: IUpdate, additionalCc?: MiLocalUser['id']): Promise<string> { private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise<string> {
if (actor.uri !== activity.actor) { if (actor.uri !== activity.actor) {
return 'skip: invalid actor'; return 'skip: invalid actor';
} }
this.logger.debug('Update'); this.logger.debug('Update');
const resolver = this.apResolverService.createResolver(); // eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => { const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`, { error: e }); this.logger.error(`Resolution failed: ${e}`, { error: e });
@ -766,7 +809,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 => this.logger.error(`err: failed to update question: ${err}`, { error: err })); await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => this.logger.error(`err: failed to update question: ${err}`, { error: err }));
return 'ok: Question updated'; return 'ok: Question updated';
} else if (additionalCc && isPost(object)) { } else if (additionalCc && isPost(object)) {
const uri = getApId(object); const uri = getApId(object);
@ -790,16 +833,16 @@ export class ApInboxService {
unlock(); unlock();
} }
} else { } else {
return `skip: Unknown type: ${getApType(object)}`; return `skip: Unknown type: ${getApType(object) ?? 'undefined'}}`;
} }
} }
@bindThis @bindThis
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> { private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
// fetch the new and old accounts // fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target); const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target'; if (!targetUri) return 'skip: invalid activity target';
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
} }
} }

View file

@ -6,15 +6,18 @@
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
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 type { IObject } from './type.js';
type Request = { type Request = {
url: string; url: string;
@ -144,6 +147,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,
) { ) {
this.logger = this.loggerService.getLogger('ap:request'); this.logger = this.loggerService.getLogger('ap:request');
} }
@ -177,9 +181,11 @@ export class ApRequestService {
* Get AP object with http-signature * Get AP object with http-signature
* @param user http-signature user * @param user http-signature user
* @param url URL to fetch * @param url URL to fetch
* @param followAlternate If true, follow alternate link tag in HTML
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> { public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id); const keypair = await this.userKeypairService.getUserKeypair(user.id);
const req = ApRequestCreator.createSignedGet({ const req = ApRequestCreator.createSignedGet({
@ -197,9 +203,39 @@ export class ApRequestService {
headers: req.request.headers, headers: req.request.headers,
}, { }, {
throwErrorWhenResponseNotOk: true, throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
}); });
return await res.json(); //#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき
const contentType = res.headers.get('content-type');
if (
res.ok &&
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
_followAlternate
) {
const html = await res.text();
try {
const fragment = JSDOM.fragment(html);
const alternate = fragment.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) {
const href = alternate.getAttribute('href');
if (href && this.utilityService.isRelatedUris(url, href)) {
return await this.signedGet(href, user, false);
}
}
} catch (e) {
// something went wrong parsing the HTML, ignore the whole thing
}
}
//#endregion
validateContentTypeSetAsActivityPub(res);
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
this.utilityService.assertActivityRelatedToUrl(activity, finalUrl);
return activity;
} }
} }

View file

@ -42,7 +42,7 @@ export class Resolver {
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService, private loggerService: LoggerService,
private recursionLimit = 100, private recursionLimit = 256,
) { ) {
this.history = new Set(); this.history = new Set();
this.logger = this.loggerService.getLogger('ap:resolve'); this.logger = this.loggerService.getLogger('ap:resolve');
@ -53,6 +53,11 @@ 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'
@ -84,18 +89,18 @@ export class Resolver {
} }
if (this.history.size > this.recursionLimit && (this.user?.followersCount ?? 0) > 0) { if (this.history.size > this.recursionLimit && (this.user?.followersCount ?? 0) > 0) {
throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`); throw new Error(`hit recursion limit: ${this.utilityService.extractHost(value)}`);
} }
this.history.add(value); this.history.add(value);
const host = this.utilityService.extractDbHost(value); const host = this.utilityService.extractHost(value);
if (this.utilityService.isSelfHost(host)) { if (this.utilityService.isSelfHost(host)) {
return await this.resolveLocal(value); return await this.resolveLocal(value);
} }
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { if (this.utilityService.isItemListedIn(host, meta.blockedHosts)) {
throw new Error('Instance is blocked'); throw new Error('Instance is blocked');
} }

View file

@ -80,24 +80,41 @@ 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 expectedHost = this.utilityService.extractHost(uri);
const apType = getApType(object);
if (!validPost.includes(getApType(object))) { if (apType == null || !validPost.includes(apType)) {
return new Error(`invalid Note: invalid object type ${getApType(object)}`); return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`);
} }
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { let actualHost = object.id && this.utilityService.extractHost(object.id);
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); if (actualHost && !this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
} }
const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); actualHost = object.attributedTo && this.utilityService.extractHost(getOneApId(object.attributedTo));
if (object.attributedTo && actualHost !== expectHost) { if (actualHost && !this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
} }
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
return new Error('invalid Note: published timestamp is malformed'); return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
}
if (actor?.uri) {
if (!this.utilityService.isRelatedUris(uri, actor.uri)) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: object has unrelated host to actor. actor: ${actor.uri}, object: ${uri}`);
}
if (object.id && !this.utilityService.isRelatedUris(object.id, actor.uri)) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has unrelated host to actor. actor: ${actor.uri}, id: ${object.id}`);
}
const attributedTo = object.attributedTo && getOneApId(object.attributedTo);
if (attributedTo && !this.utilityService.isRelatedUris(attributedTo, actor.uri)) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has unrelated host to actor. actor: ${actor.uri}, attributedTo: ${attributedTo}`);
}
} }
return null; return null;
@ -117,7 +134,7 @@ 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();
@ -126,7 +143,7 @@ export class ApNoteService {
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() },
@ -141,14 +158,24 @@ 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);
}
if (!this.utilityService.isRelatedUris(note.id, url)) {
throw new Error(`note id and url has unrelated host: ${note.id} - ${url}`);
}
} }
this.logger.info(`Creating the Note: ${note.id}`); this.logger.info(`Creating the Note: ${note.id}`);
@ -161,9 +188,10 @@ 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;
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `User ${cachedActor.id} has been suspended.`); if (actor && actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `User ${actor.id} has been suspended.`);
} }
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
@ -194,7 +222,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) {
@ -213,7 +242,7 @@ export class ApNoteService {
} }
} }
const isSensitiveMediaHost = this.utilityService.isSensitiveMediaHost(meta.sensitiveMediaHosts, this.utilityService.extractDbHost(note.id ?? entryUri)); const isSensitiveMediaHost = this.utilityService.isItemListedIn(this.utilityService.extractHost(note.id ?? entryUri), meta.sensitiveMediaHosts);
// 添付ファイル // 添付ファイル
const files: MiDriveFile[] = []; const files: MiDriveFile[] = [];
@ -328,7 +357,7 @@ export class ApNoteService {
this.logger.info('The note is already inserted while creating itself, reading again'); this.logger.info('The note is already inserted while creating itself, reading again');
const duplicate = await this.fetchNote(value); const duplicate = await this.fetchNote(value);
if (!duplicate) { if (!duplicate) {
throw new Error('The note creation failed with duplication error even when there is no duplication'); throw new Error(`The note creation failed with duplication error even when there is no duplication: ${entryUri}`);
} }
return duplicate; return duplicate;
} }
@ -346,7 +375,7 @@ export class ApNoteService {
// ブロックしていたら中断 // ブロックしていたら中断
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) { if (this.utilityService.isItemListedIn(this.utilityService.extractHost(uri), meta.blockedHosts)) {
throw new StatusError('blocked host', 451); throw new StatusError('blocked host', 451);
} }
@ -358,7 +387,7 @@ export class ApNoteService {
if (exist) return exist; if (exist) return exist;
//#endregion //#endregion
if (new URL(uri).origin === this.config.url) { if (this.utilityService.isUriLocal(uri)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
} }
@ -366,7 +395,7 @@ export class ApNoteService {
// ここで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();
} }
@ -375,7 +404,7 @@ export class ApNoteService {
@bindThis @bindThis
public async extractEmojis(tags: IObject | IObject[], host: string): Promise<MiEmoji[]> { public async extractEmojis(tags: IObject | IObject[], host: string): Promise<MiEmoji[]> {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
host = this.utilityService.toPuny(host); host = this.utilityService.normalizeHost(host);
const eomjiTags = toArray(tags).filter(isEmoji); const eomjiTags = toArray(tags).filter(isEmoji);

View file

@ -49,7 +49,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
import type { ApLoggerService } from '../ApLoggerService.js'; import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js'; import type { ApImageService } from './ApImageService.js';
import type { IActor, IObject } from '../type.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
const nameLength = 128; const nameLength = 128;
const summaryLength = 2048; const summaryLength = 2048;
@ -130,12 +130,6 @@ export class ApPersonService implements OnModuleInit {
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;
} }
private punyHost(url: string): string {
const urlObj = new URL(url);
const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
return host;
}
/** /**
* Validate and convert to actor object * Validate and convert to actor object
* @param x Fetched object * @param x Fetched object
@ -143,7 +137,7 @@ export class ApPersonService implements OnModuleInit {
*/ */
@bindThis @bindThis
private validateActor(x: IObject, uri: string): IActor { private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.punyHost(uri); const expectedHost = this.utilityService.extractHost(uri);
if (!isActor(x)) { if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`); throw new Error(`invalid Actor type '${x.type}'`);
@ -157,18 +151,30 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: wrong inbox'); throw new Error('invalid Actor: wrong inbox');
} }
try { let actualHost = this.utilityService.extractHost(x.inbox);
new URL(x.inbox); if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
} catch { throw new Error(`invalid Actor: inbox has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
throw new Error('invalid Actor: wrong inbox');
} }
const sharedInbox = x.sharedInbox ?? x.endpoints?.sharedInbox; const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (typeof sharedInbox === 'string') { if (sharedInboxObject != null) {
try { const sharedInbox = getApId(sharedInboxObject);
new URL(sharedInbox); if (!sharedInbox) throw new Error('invalid Actor: wrong shared inbox');
} catch { actualHost = this.utilityService.extractHost(sharedInbox);
throw new Error('invalid Actor: wrong sharedInbox'); if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: shared inbox has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
}
}
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
const xCollection = (x as IActor)[collection];
if (xCollection != null) {
const collectionUri = getApId(xCollection);
if (!collectionUri) throw new Error(`invalid Actor: wrong ${collection}`);
actualHost = this.utilityService.extractHost(collectionUri);
if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error(`invalid Actor: ${collection} has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
}
} }
} }
@ -195,9 +201,9 @@ export class ApPersonService implements OnModuleInit {
x.summary = truncate(x.summary, summaryLength); x.summary = truncate(x.summary, summaryLength);
} }
const idHost = this.punyHost(x.id); actualHost = this.utilityService.extractHost(x.id);
if (idHost !== expectHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error('invalid Actor: id has different host'); throw new Error(`invalid Actor: id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
} }
if (x.publicKey) { if (x.publicKey) {
@ -205,9 +211,9 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: publicKey.id is not a string'); throw new Error('invalid Actor: publicKey.id is not a string');
} }
const publicKeyIdHost = this.punyHost(x.publicKey.id); actualHost = this.utilityService.extractHost(x.publicKey.id);
if (publicKeyIdHost !== expectHost) { if (!this.utilityService.isRelatedHosts(expectedHost, actualHost)) {
throw new Error('invalid Actor: publicKey.id has different host'); throw new Error(`invalid Actor: publicKey.id has unrelated host. expected: ${expectedHost}, actual: ${actualHost}`);
} }
} }
@ -248,6 +254,12 @@ export class ApPersonService implements OnModuleInit {
if (user == null) throw new Error('failed to create user: user is null'); if (user == null) throw new Error('failed to create user: user is null');
const [avatar, banner] = await Promise.all([icon, image].map(img => { const [avatar, banner] = await Promise.all([icon, image].map(img => {
// icon and image may be arrays
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
if (Array.isArray(img)) {
img = img.find(item => item && item.url) ?? null;
}
// if we have an explicitly missing image, return an // if we have an explicitly missing image, return an
// explicitly-null set of values // explicitly-null set of values
if ((img == null) || (typeof img === 'object' && img.url == null)) { if ((img == null) || (typeof img === 'object' && img.url == null)) {
@ -285,7 +297,7 @@ export class ApPersonService implements OnModuleInit {
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 (new URL(uri).origin === this.config.url) { if (this.utilityService.isUriLocal(uri)) {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
} }
@ -299,24 +311,48 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Creating the Person: ${person.id}`); this.logger.info(`Creating the Person: ${person.id}`);
const host = this.punyHost(object.id);
const fields = this.analyzeAttachments(person.attachment ?? []); const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; const isBot = getApType(object) === 'Service' || getApType(object) === 'Application';
const [followingVisibility, followersVisibility] = await Promise.all(
[
this.isPublicCollection(person.following, resolver),
this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private'> => p
.then(isPublic => isPublic ? 'public' : 'private')
.catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) {
this.logger.error('error occurred while fetching following/followers collection', { error: err });
}
return 'private';
})
)
);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url); const url = getOneApHrefNullable(person.url);
if (url && !checkHttps(url)) { if (person.id == null) {
throw new Error('unexpected schema of person url: ' + url); throw new Error('Refusing to create person without id');
}
if (url != null) {
if (!checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
}
if (!this.utilityService.isRelatedUris(person.id, url)) {
throw new Error(`person id and url has unrelated host: ${person.id} - ${url}`);
}
} }
// Create user // Create user
let user: MiRemoteUser | null = null; let user: MiRemoteUser | null = null;
const host = this.utilityService.extractHost(uri);
//#region カスタム絵文字取得 //#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host) const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
@ -345,7 +381,7 @@ export class ApPersonService implements OnModuleInit {
usernameLower: person.preferredUsername?.toLowerCase(), usernameLower: person.preferredUsername?.toLowerCase(),
host, host,
inbox: person.inbox, inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
followersUri: person.followers ? getApId(person.followers) : undefined, followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured ? getApId(person.featured) : undefined, featured: person.featured ? getApId(person.featured) : undefined,
uri: person.id, uri: person.id,
@ -368,6 +404,8 @@ export class ApPersonService implements OnModuleInit {
description: _description, description: _description,
url, url,
fields, fields,
followingVisibility,
followersVisibility,
birthday: bday?.[0] ?? null, birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null, location: person['vcard:Address'] ?? null,
userHost: host, userHost: host,
@ -447,7 +485,7 @@ export class ApPersonService implements OnModuleInit {
if (typeof uri !== 'string') throw new Error('uri is not string'); if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ // URIがこのサーバーを指しているならスキップ
if (new URL(uri).origin === this.config.url) return; if (this.utilityService.isUriLocal(uri)) return;
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const exist = await this.fetchPerson(uri) as MiRemoteUser | null; const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
@ -475,12 +513,39 @@ export class ApPersonService implements OnModuleInit {
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const [followingVisibility, followersVisibility] = await Promise.all(
[
this.isPublicCollection(person.following, resolver),
this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private' | undefined> => p
.then(isPublic => isPublic ? 'public' : 'private')
.catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) {
this.logger.error('error occurred while fetching following/followers collection', { error: err });
// Do not update the visibiility on transient errors.
return undefined;
}
return 'private';
})
)
);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url); const url = getOneApHrefNullable(person.url);
if (url && !checkHttps(url)) { if (person.id == null) {
throw new Error('unexpected schema of person url: ' + url); throw new Error('Refusing to update person without id');
}
if (url != null) {
if (!checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
}
if (!this.utilityService.isRelatedUris(person.id, url)) {
throw new Error(`person id and url has unrelated host: ${person.id} - ${url}`);
}
} }
const policy = await this.roleService.getUserPolicies(exist.id); const policy = await this.roleService.getUserPolicies(exist.id);
@ -488,7 +553,7 @@ export class ApPersonService implements OnModuleInit {
const updates = { const updates = {
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
inbox: person.inbox, inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
followersUri: person.followers ? getApId(person.followers) : undefined, followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured, featured: person.featured,
emojis: emojiNames, emojis: emojiNames,
@ -545,6 +610,8 @@ export class ApPersonService implements OnModuleInit {
url, url,
fields, fields,
description: _description, description: _description,
followingVisibility,
followersVisibility,
birthday: bday?.[0] ?? null, birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null, location: person['vcard:Address'] ?? null,
}); });
@ -557,7 +624,7 @@ export class ApPersonService implements OnModuleInit {
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
await this.followingsRepository.update( await this.followingsRepository.update(
{ followerId: exist.id }, { followerId: exist.id },
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox }, { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null },
); );
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
@ -689,7 +756,7 @@ export class ApPersonService implements OnModuleInit {
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]); await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
dst = await this.fetchPerson(src.movedToUri) ?? dst; dst = await this.fetchPerson(src.movedToUri) ?? dst;
} else { } else {
if (new URL(src.movedToUri).origin === this.config.url) { if (this.utilityService.isUriLocal(src.movedToUri)) {
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
return 'failed: movedTo is local but not found'; return 'failed: movedTo is local but not found';
} }
@ -715,4 +782,16 @@ export class ApPersonService implements OnModuleInit {
return 'ok'; return 'ok';
} }
@bindThis
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
if (collection) {
const resolved = await resolver.resolveCollection(collection);
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
return true;
}
}
return false;
}
} }

View file

@ -5,17 +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 { isNotNull } from '@/misc/is-not-null.js'; import { isNotNull } from '@/misc/is-not-null.js';
import { isQuestion } from '../type.js'; import { UtilityService } from '@/core/UtilityService.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';
@Injectable() @Injectable()
export class ApQuestionService { export class ApQuestionService {
@ -25,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,
@ -33,6 +38,7 @@ export class ApQuestionService {
private apResolverService: ApResolverService, private apResolverService: ApResolverService,
private apLoggerService: ApLoggerService, private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) { ) {
this.logger = this.apLoggerService.logger; this.logger = this.apLoggerService.logger;
} }
@ -66,28 +72,39 @@ 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 uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null'); if (uri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ // URIがこのサーバーを指しているならスキップ
if (new URL(uri).origin === this.config.url) throw new Error('uri points local'); if (this.utilityService.isUriLocal(uri)) throw new Error('uri points local');
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const note = await this.notesRepository.findOneBy({ uri }); const note = await this.notesRepository.findOneBy({ uri });
if (note == null) throw new Error('Question is not registed'); if (note == null) throw new Error('Question is not registed');
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 registed'); 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('User 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);
@ -97,7 +114,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;

View file

@ -60,11 +60,14 @@ export function getApId(value: string | IObject): string {
/** /**
* Get ActivityStreams Object type * Get ActivityStreams Object type
*
* nullを返すようにしている
* 詳細: https://github.com/misskey-dev/misskey/issues/14239
*/ */
export function getApType(value: IObject): string { export function getApType(value: IObject): string | null {
if (typeof value.type === 'string') return value.type; if (typeof value.type === 'string') return value.type;
if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0]; if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0];
throw new Error('cannot detect type'); return null;
} }
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined { export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
@ -97,19 +100,23 @@ export interface IActivity extends IObject {
export interface ICollection extends IObject { export interface ICollection extends IObject {
type: 'Collection'; type: 'Collection';
totalItems: number; totalItems: number;
items: ApObject; first?: IObject | string;
items?: ApObject;
} }
export interface IOrderedCollection extends IObject { export interface IOrderedCollection extends IObject {
type: 'OrderedCollection'; type: 'OrderedCollection';
totalItems: number; totalItems: number;
orderedItems: ApObject; first?: IObject | string;
orderedItems?: ApObject;
} }
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
export const isPost = (object: IObject): object is IPost => export const isPost = (object: IObject): object is IPost => {
validPost.includes(getApType(object)); const type = getApType(object);
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';
@ -156,8 +163,10 @@ export const isTombstone = (object: IObject): object is ITombstone =>
export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application'];
export const isActor = (object: IObject): object is IActor => export const isActor = (object: IObject): object is IActor => {
validActor.includes(getApType(object)); const type = getApType(object);
return type != null && validActor.includes(type);
};
export interface IActor extends IObject { export interface IActor extends IObject {
type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application';
@ -240,12 +249,16 @@ export interface IKey extends IObject {
publicKeyPem: string | Buffer; publicKeyPem: string | Buffer;
} }
export const validDocumentTypes = ['Audio', 'Document', 'Image', 'Page', 'Video'];
export interface IApDocument extends IObject { export interface IApDocument extends IObject {
type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video'; type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video';
} }
export const isDocument = (object: IObject): object is IApDocument => export const isDocument = (object: IObject): object is IApDocument => {
['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object)); const type = getApType(object);
return type != null && validDocumentTypes.includes(type);
};
export interface IApImage extends IApDocument { export interface IApImage extends IApDocument {
type: 'Image'; type: 'Image';
@ -323,8 +336,12 @@ export const isAccept = (object: IObject): object is IAccept => getApType(object
export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject'; export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject';
export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add'; export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add';
export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove'; export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove';
export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact'; export const isLike = (object: IObject): object is ILike => {
const type = getApType(object);
return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type);
};
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';
export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note';

View file

@ -77,7 +77,7 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
public async requestReceived(host: string): Promise<void> { public async requestReceived(host: string): Promise<void> {
await this.commit({ await this.commit({
'requests.received': 1, 'requests.received': 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.normalizeHost(host));
} }
@bindThis @bindThis
@ -85,7 +85,7 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
await this.commit({ await this.commit({
'requests.succeeded': isSucceeded ? 1 : 0, 'requests.succeeded': isSucceeded ? 1 : 0,
'requests.failed': isSucceeded ? 0 : 1, 'requests.failed': isSucceeded ? 0 : 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.normalizeHost(host));
} }
@bindThis @bindThis
@ -93,7 +93,7 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
await this.commit({ await this.commit({
'users.total': 1, 'users.total': 1,
'users.inc': 1, 'users.inc': 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.normalizeHost(host));
} }
@bindThis @bindThis
@ -106,7 +106,7 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, 'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0,
'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, 'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0,
'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, 'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0,
}, this.utilityService.toPuny(host)); }, this.utilityService.normalizeHost(host));
} }
@bindThis @bindThis
@ -115,7 +115,7 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
'following.total': isAdditional ? 1 : -1, 'following.total': isAdditional ? 1 : -1,
'following.inc': isAdditional ? 1 : 0, 'following.inc': isAdditional ? 1 : 0,
'following.dec': isAdditional ? 0 : 1, 'following.dec': isAdditional ? 0 : 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.normalizeHost(host));
} }
@bindThis @bindThis
@ -124,7 +124,7 @@ export default class InstanceChart extends Chart<typeof schema> { // eslint-disa
'followers.total': isAdditional ? 1 : -1, 'followers.total': isAdditional ? 1 : -1,
'followers.inc': isAdditional ? 1 : 0, 'followers.inc': isAdditional ? 1 : 0,
'followers.dec': isAdditional ? 0 : 1, 'followers.dec': isAdditional ? 0 : 1,
}, this.utilityService.toPuny(host)); }, this.utilityService.normalizeHost(host));
} }
@bindThis @bindThis

View file

@ -158,7 +158,7 @@ export class DriveFileEntityService {
public async calcDriveUsageOfHost(host: string): Promise<number> { public async calcDriveUsageOfHost(host: string): Promise<number> {
const { sum } = await this.driveFilesRepository const { sum } = await this.driveFilesRepository
.createQueryBuilder('file') .createQueryBuilder('file')
.where('file.userHost = :host', { host: this.utilityService.toPuny(host) }) .where('file.userHost = :host', { host: this.utilityService.normalizeHost(host) })
.andWhere('file.isLink = FALSE') .andWhere('file.isLink = FALSE')
.select('SUM(file.size)', 'sum') .select('SUM(file.size)', 'sum')
.getRawOne(); .getRawOne();

View file

@ -40,7 +40,7 @@ export class InstanceEntityService {
followersCount: instance.followersCount, followersCount: instance.followersCount,
isNotResponding: instance.isNotResponding, isNotResponding: instance.isNotResponding,
isSuspended: instance.isSuspended, isSuspended: instance.isSuspended,
isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host), isBlocked: this.utilityService.isItemListedIn(instance.host, meta.blockedHosts),
softwareName: instance.softwareName, softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion, softwareVersion: instance.softwareVersion,
openRegistrations: instance.openRegistrations, openRegistrations: instance.openRegistrations,
@ -48,8 +48,8 @@ export class InstanceEntityService {
description: instance.description, description: instance.description,
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host), isSilenced: this.utilityService.isItemListedIn(instance.host, meta.silencedHosts),
isSensitiveMedia: this.utilityService.isSensitiveMediaHost(meta.sensitiveMediaHosts, instance.host), isSensitiveMedia: this.utilityService.isItemListedIn(instance.host, meta.sensitiveMediaHosts),
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,

View file

@ -474,12 +474,12 @@ export class UserEntityService implements OnModuleInit {
} }
const followingCount = profile == null ? null : const followingCount = profile == null ? null :
(profile.followingVisibility === 'public') || isMe ? user.followingCount : (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
(profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount : (profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount :
null; null;
const followersCount = profile == null ? null : const followersCount = profile == null ? null :
(profile.followersVisibility === 'public') || isMe ? user.followersCount : (profile.followersVisibility === 'public') || isMe || iAmModerator ? user.followersCount :
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : (profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
null; null;

View file

@ -7,23 +7,23 @@ import * as Redis from 'ioredis';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
export class RedisKVCache<T> { export class RedisKVCache<T> {
private redisClient: Redis.Redis; private readonly lifetime: number;
private name: string; private readonly memoryCache: MemoryKVCache<T>;
private lifetime: number; private readonly fetcher: (key: string) => Promise<T>;
private memoryCache: MemoryKVCache<T>; private readonly toRedisConverter: (value: T) => string;
private fetcher: (key: string) => Promise<T>; private readonly fromRedisConverter: (value: string) => T | undefined;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { constructor(
lifetime: RedisKVCache<T>['lifetime']; private redisClient: Redis.Redis,
memoryCacheLifetime: number; private name: string,
fetcher: RedisKVCache<T>['fetcher']; opts: {
toRedisConverter: RedisKVCache<T>['toRedisConverter']; lifetime: RedisKVCache<T>['lifetime'];
fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; memoryCacheLifetime: number;
}) { fetcher: RedisKVCache<T>['fetcher'];
this.redisClient = redisClient; toRedisConverter: RedisKVCache<T>['toRedisConverter'];
this.name = name; fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@ -55,10 +55,13 @@ export class RedisKVCache<T> {
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
if (cached == null) return undefined; if (cached == null) return undefined;
const parsed = this.fromRedisConverter(cached);
if (parsed == null) return undefined; const value = this.fromRedisConverter(cached);
this.memoryCache.set(key, parsed); if (value !== undefined) {
return parsed; this.memoryCache.set(key, value);
}
return value;
} }
@bindThis @bindThis
@ -69,6 +72,10 @@ export class RedisKVCache<T> {
/** /**
* fetcherを呼び出して結果をキャッシュ& * fetcherを呼び出して結果をキャッシュ&
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/ */
@bindThis @bindThis
public async fetch(key: string): Promise<T> { public async fetch(key: string): Promise<T> {
@ -80,14 +87,14 @@ export class RedisKVCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(key); const value = await this.fetcher(key);
this.set(key, value); await this.set(key, value);
return value; return value;
} }
@bindThis @bindThis
public async refresh(key: string) { public async refresh(key: string) {
const value = await this.fetcher(key); const value = await this.fetcher(key);
this.set(key, value); await this.set(key, value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@ -104,23 +111,23 @@ export class RedisKVCache<T> {
} }
export class RedisSingleCache<T> { export class RedisSingleCache<T> {
private redisClient: Redis.Redis; private readonly lifetime: number;
private name: string; private readonly memoryCache: MemorySingleCache<T>;
private lifetime: number; private readonly fetcher: () => Promise<T>;
private memoryCache: MemorySingleCache<T>; private readonly toRedisConverter: (value: T) => string;
private fetcher: () => Promise<T>; private readonly fromRedisConverter: (value: string) => T | undefined;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { constructor(
lifetime: RedisSingleCache<T>['lifetime']; private redisClient: Redis.Redis,
memoryCacheLifetime: number; private name: string,
fetcher: RedisSingleCache<T>['fetcher']; opts: {
toRedisConverter: RedisSingleCache<T>['toRedisConverter']; lifetime: number;
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; memoryCacheLifetime: number;
}) { fetcher: RedisSingleCache<T>['fetcher'];
this.redisClient = redisClient; toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
this.name = name; fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
},
) {
this.lifetime = opts.lifetime; this.lifetime = opts.lifetime;
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
this.fetcher = opts.fetcher; this.fetcher = opts.fetcher;
@ -152,10 +159,13 @@ export class RedisSingleCache<T> {
const cached = await this.redisClient.get(`singlecache:${this.name}`); const cached = await this.redisClient.get(`singlecache:${this.name}`);
if (cached == null) return undefined; if (cached == null) return undefined;
const parsed = this.fromRedisConverter(cached);
if (parsed == null) return undefined; const value = this.fromRedisConverter(cached);
this.memoryCache.set(parsed); if (value !== undefined) {
return parsed; this.memoryCache.set(value);
}
return value;
} }
@bindThis @bindThis
@ -166,6 +176,10 @@ export class RedisSingleCache<T> {
/** /**
* fetcherを呼び出して結果をキャッシュ& * fetcherを呼び出して結果をキャッシュ&
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/ */
@bindThis @bindThis
public async fetch(): Promise<T> { public async fetch(): Promise<T> {
@ -177,14 +191,14 @@ export class RedisSingleCache<T> {
// Cache MISS // Cache MISS
const value = await this.fetcher(); const value = await this.fetcher();
this.set(value); await this.set(value);
return value; return value;
} }
@bindThis @bindThis
public async refresh() { public async refresh() {
const value = await this.fetcher(); const value = await this.fetcher();
this.set(value); await this.set(value);
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
} }
@ -197,18 +211,12 @@ export class MemoryKVCache<T> {
* *
* *
*/ */
public cache: Map<string, { date: number; value: T; }>; private readonly cache = new Map<string, { date: number; value: T; }>();
private lifetime: number; private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
private gcIntervalHandle: NodeJS.Timeout;
constructor(lifetime: MemoryKVCache<never>['lifetime']) { constructor(
this.cache = new Map(); private readonly lifetime: number,
this.lifetime = lifetime; ) {}
this.gcIntervalHandle = setInterval(() => {
this.gc();
}, 1000 * 60 * 3);
}
@bindThis @bindThis
/** /**
@ -293,10 +301,14 @@ export class MemoryKVCache<T> {
@bindThis @bindThis
public gc(): void { public gc(): void {
const now = Date.now(); const now = Date.now();
for (const [key, { date }] of this.cache.entries()) { for (const [key, { date }] of this.cache.entries()) {
if ((now - date) > this.lifetime) { // The map is ordered from oldest to youngest.
this.cache.delete(key); // We can stop once we find an entry that's still active, because all following entries must *also* be active.
} const age = now - date;
if (age < this.lifetime) break;
this.cache.delete(key);
} }
} }
@ -304,16 +316,19 @@ export class MemoryKVCache<T> {
public dispose(): void { public dispose(): void {
clearInterval(this.gcIntervalHandle); clearInterval(this.gcIntervalHandle);
} }
public get entries() {
return this.cache.entries();
}
} }
export class MemorySingleCache<T> { export class MemorySingleCache<T> {
private cachedAt: number | null = null; private cachedAt: number | null = null;
private value: T | undefined; private value: T | undefined;
private lifetime: number;
constructor(lifetime: MemorySingleCache<never>['lifetime']) { constructor(
this.lifetime = lifetime; private lifetime: number,
} ) {}
@bindThis @bindThis
public set(value: T): void { public set(value: T): void {

View file

@ -52,4 +52,11 @@ export class MiRoleAssignment {
nullable: true, nullable: true,
}) })
public expiresAt: Date | null; public expiresAt: Date | null;
@Column('varchar', {
comment: 'memo for the role assignment',
length: 256,
nullable: true,
})
public memo: string | null;
} }

View file

@ -45,7 +45,7 @@ import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { AutoNoteRemovalProcessorService } from './processors/AutoNoteRemovalProcessorService.js'; import { AutoNoteRemovalProcessorService } from './processors/AutoNoteRemovalProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueLoggerService } from './QueueLoggerService.js';
import { QUEUE, baseWorkerOptions } from './const.js'; import { QUEUE, baseWorkerOptions, formatQueueName } from './const.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) {
@ -217,7 +217,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region deliver //#region deliver
this.deliverQueueWorkers = this.config.redisForDeliverQueues this.deliverQueueWorkers = this.config.redisForDeliverQueues
.filter((_, index) => process.env.QUEUE_WORKER_INDEX == null || index === Number.parseInt(process.env.QUEUE_WORKER_INDEX, 10)) .filter((_, index) => process.env.QUEUE_WORKER_INDEX == null || index === Number.parseInt(process.env.QUEUE_WORKER_INDEX, 10))
.map(config => new Bull.Worker(QUEUE.DELIVER, (job) => this.deliverProcessorService.process(job), { .map(config => new Bull.Worker(formatQueueName(config, QUEUE.DELIVER), (job) => this.deliverProcessorService.process(job), {
...baseWorkerOptions(config, this.config.bullmqWorkerOptions, QUEUE.DELIVER), ...baseWorkerOptions(config, this.config.bullmqWorkerOptions, QUEUE.DELIVER),
autorun: false, autorun: false,
concurrency: this.config.deliverJobConcurrency ?? 128, concurrency: this.config.deliverJobConcurrency ?? 128,
@ -245,7 +245,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region inbox //#region inbox
this.inboxQueueWorkers = this.config.redisForInboxQueues this.inboxQueueWorkers = this.config.redisForInboxQueues
.filter((_, index) => process.env.QUEUE_WORKER_INDEX == null || index === Number.parseInt(process.env.QUEUE_WORKER_INDEX, 10)) .filter((_, index) => process.env.QUEUE_WORKER_INDEX == null || index === Number.parseInt(process.env.QUEUE_WORKER_INDEX, 10))
.map(config => new Bull.Worker(QUEUE.INBOX, (job) => this.inboxProcessorService.process(job), { .map(config => new Bull.Worker(formatQueueName(config, QUEUE.INBOX), (job) => this.inboxProcessorService.process(job), {
...baseWorkerOptions(config, this.config.bullmqWorkerOptions, QUEUE.INBOX), ...baseWorkerOptions(config, this.config.bullmqWorkerOptions, QUEUE.INBOX),
autorun: false, autorun: false,
concurrency: this.config.inboxJobConcurrency ?? 16, concurrency: this.config.inboxJobConcurrency ?? 16,
@ -297,7 +297,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
//#region relationship //#region relationship
this.relationshipQueueWorkers = this.config.redisForRelationshipQueues this.relationshipQueueWorkers = this.config.redisForRelationshipQueues
.filter((_, index) => process.env.QUEUE_WORKER_INDEX == null || index === Number.parseInt(process.env.QUEUE_WORKER_INDEX, 10)) .filter((_, index) => process.env.QUEUE_WORKER_INDEX == null || index === Number.parseInt(process.env.QUEUE_WORKER_INDEX, 10))
.map(config => new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { .map(config => new Bull.Worker(formatQueueName(config, QUEUE.RELATIONSHIP), (job) => {
switch (job.name) { switch (job.name) {
case 'follow': return this.relationshipProcessorService.processFollow(job); case 'follow': return this.relationshipProcessorService.processFollow(job);
case 'unfollow': return this.relationshipProcessorService.processUnfollow(job); case 'unfollow': return this.relationshipProcessorService.processUnfollow(job);

View file

@ -18,7 +18,12 @@ export const QUEUE = {
WEBHOOK_DELIVER: 'webhookDeliver', WEBHOOK_DELIVER: 'webhookDeliver',
}; };
export function formatQueueName(config: RedisOptionsSource, queueName: typeof QUEUE[keyof typeof QUEUE]): string {
return typeof config.queueNameSuffix === 'string' ? `${queueName}-${config.queueNameSuffix}` : queueName;
}
export function baseQueueOptions(config: RedisOptions & RedisOptionsSource, queueOptions: Partial<Bull.QueueOptions>, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { export function baseQueueOptions(config: RedisOptions & RedisOptionsSource, queueOptions: Partial<Bull.QueueOptions>, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
const name = formatQueueName(config, queueName);
return { return {
...queueOptions, ...queueOptions,
connection: { connection: {
@ -33,11 +38,12 @@ export function baseQueueOptions(config: RedisOptions & RedisOptionsSource, queu
return 1; return 1;
}, },
}, },
prefix: config.prefix ? `${config.prefix}:queue:${queueName}` : `queue:${queueName}`, prefix: config.prefix ? `{${config.prefix}:queue:${name}}` : `{queue:${name}}`,
}; };
} }
export function baseWorkerOptions(config: RedisOptions & RedisOptionsSource, workerOptions: Partial<Bull.WorkerOptions>, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions { export function baseWorkerOptions(config: RedisOptions & RedisOptionsSource, workerOptions: Partial<Bull.WorkerOptions>, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions {
const name = formatQueueName(config, queueName);
return { return {
...workerOptions, ...workerOptions,
connection: { connection: {
@ -52,6 +58,6 @@ export function baseWorkerOptions(config: RedisOptions & RedisOptionsSource, wor
return 1; return 1;
}, },
}, },
prefix: config.prefix ? `${config.prefix}:queue:${queueName}` : `queue:${queueName}`, prefix: config.prefix ? `{${config.prefix}:queue:${name}}` : `{queue:${name}}`,
}; };
} }

View file

@ -44,7 +44,7 @@ export class DeliverProcessorService {
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); // 1h
} }
@bindThis @bindThis
@ -53,7 +53,7 @@ export class DeliverProcessorService {
// ブロックしてたら中断 // ブロックしてたら中断
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.toPuny(host))) { if (this.utilityService.isItemListedIn(host, meta.blockedHosts)) {
return 'skip (blocked)'; return 'skip (blocked)';
} }
@ -67,7 +67,7 @@ export class DeliverProcessorService {
}); });
this.suspendedHostsCache.set(suspendedHosts); this.suspendedHostsCache.set(suspendedHosts);
} }
if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { if (suspendedHosts.map(x => x.host).includes(this.utilityService.normalizeHost(host))) {
return 'skip (suspended)'; return 'skip (suspended)';
} }

View file

@ -76,7 +76,7 @@ export class ImportBlockingProcessorService {
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host), host: this.utilityService.normalizeHost(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });

View file

@ -76,7 +76,7 @@ export class ImportFollowingProcessorService {
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host), host: this.utilityService.normalizeHost(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });

View file

@ -71,7 +71,7 @@ export class ImportMutingProcessorService {
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host), host: this.utilityService.normalizeHost(host),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });

View file

@ -90,7 +90,7 @@ export class ImportUserListsProcessorService {
host: IsNull(), host: IsNull(),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}) : await this.usersRepository.findOneBy({ }) : await this.usersRepository.findOneBy({
host: this.utilityService.toPuny(host!), host: this.utilityService.normalizeHost(host!),
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
}); });

View file

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { URL } from 'node:url';
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { 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';
@ -65,11 +64,11 @@ export class InboxProcessorService implements OnApplicationShutdown {
this.logger.debug(JSON.stringify(info, null, 2)); this.logger.debug(JSON.stringify(info, null, 2));
//#endregion //#endregion
const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); const host = this.utilityService.extractHost(signature.keyId);
// ブロックしてたら中断 // ブロックしてたら中断
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { if (this.utilityService.isItemListedIn(host, meta.blockedHosts)) {
return `Blocked request: ${host}`; return `Blocked request: ${host}`;
} }
@ -164,8 +163,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
} }
// ブロックしてたら中断 // ブロックしてたら中断
const ldHost = this.utilityService.extractDbHost(authUser.user.uri); const ldHost = this.utilityService.extractHost(authUser.user.uri);
if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { if (this.utilityService.isItemListedIn(ldHost, meta.blockedHosts)) {
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
} }
} else { } else {
@ -175,11 +174,13 @@ export class InboxProcessorService implements OnApplicationShutdown {
// activity.idがあればホストが署名者のホストであることを確認する // activity.idがあればホストが署名者のホストであることを確認する
if (typeof activity.id === 'string') { if (typeof activity.id === 'string') {
const signerHost = this.utilityService.extractDbHost(authUser.user.uri!); const signerHost = this.utilityService.extractHost(authUser.user.uri!);
const activityIdHost = this.utilityService.extractDbHost(activity.id); const activityIdHost = this.utilityService.extractHost(activity.id);
if (signerHost !== activityIdHost) { if (signerHost !== activityIdHost) {
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');
} }
// Update stats // Update stats
@ -198,7 +199,11 @@ export class InboxProcessorService implements OnApplicationShutdown {
// アクティビティを処理 // アクティビティを処理
try { try {
await this.apInboxService.performActivity(authUser.user, activity, job.data.user?.id); const result = await this.apInboxService.performActivity(authUser.user, activity, undefined, job.data.user?.id);
if (result && !result.startsWith('ok')) {
this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`);
return result;
}
} catch (e) { } catch (e) {
if (e instanceof IdentifiableError) { if (e instanceof IdentifiableError) {
if ([ if ([
@ -206,6 +211,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
'689ee33f-f97c-479a-ac49-1b9f8140af99', '689ee33f-f97c-479a-ac49-1b9f8140af99',
'9f466dab-c856-48cd-9e65-ff90ff750580', '9f466dab-c856-48cd-9e65-ff90ff750580',
'85ab9bd7-3a41-4530-959d-f07073900109', '85ab9bd7-3a41-4530-959d-f07073900109',
'd450b8a9-48e4-4dab-ae36-f4db763fda7c',
].includes(e.id)) return e.message; ].includes(e.id)) return e.message;
} }
throw e; throw e;

View file

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

View file

@ -133,7 +133,7 @@ export class NodeinfoServerService {
return document; return document;
}; };
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m
fastify.get(nodeinfo2_1path, async (request, reply) => { fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2(21)); const base = await cache.fetch(() => nodeinfo2(21));

View file

@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
) { ) {
this.appCache = new MemoryKVCache<MiApp>(Infinity); this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w
} }
@bindThis @bindThis

View file

@ -122,10 +122,12 @@ export class SigninApiService {
return; return;
} }
const loginWithEmail = username.includes('@');
// Fetch user // Fetch user
const profile = await this.userProfilesRepository.findOne({ const profile = await this.userProfilesRepository.findOne({
relations: ['user'], relations: ['user'],
where: username.includes('@') ? { where: loginWithEmail ? {
email: username, email: username,
emailVerified: true, emailVerified: true,
user: { user: {
@ -143,21 +145,21 @@ export class SigninApiService {
if (!user || !profile) { if (!user || !profile) {
logger.error('No such user.'); logger.error('No such user.');
return error(403, { return error(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', id: loginWithEmail ? '932c904e-9460-45b7-9ce6-7ed33be7eb2c' : '6cc579cc-885d-43d8-95c2-b8c7fc963280',
}); });
} }
if (user.isDeleted && user.isSuspended) { if (user.isDeleted && user.isSuspended) {
logger.error('No such user. (logical deletion)'); logger.error('No such user. (logical deletion)');
return error(403, { return error(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', id: loginWithEmail ? '932c904e-9460-45b7-9ce6-7ed33be7eb2c' : '6cc579cc-885d-43d8-95c2-b8c7fc963280',
}); });
} }
if (user.isSuspended) { if (user.isSuspended) {
logger.error('User is suspended.'); logger.error('User is suspended.');
return error(403, { return error(403, {
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', id: loginWithEmail ? '932c904e-9460-45b7-9ce6-7ed33be7eb2c' : 'e03a5f46-d309-4865-9b69-56282d94e1eb',
}); });
} }
@ -180,27 +182,26 @@ export class SigninApiService {
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
if (meta.enableHcaptcha && meta.hcaptchaSecretKey) { try {
await this.captchaService.verifyHcaptcha(meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { if (meta.enableHcaptcha && meta.hcaptchaSecretKey) {
throw new FastifyReplyError(400, err); await this.captchaService.verifyHcaptcha(meta.hcaptchaSecretKey, body['hcaptcha-response']);
}); }
}
if (meta.enableMcaptcha && meta.mcaptchaSecretKey && meta.mcaptchaSitekey && meta.mcaptchaInstanceUrl) { if (meta.enableMcaptcha && meta.mcaptchaSecretKey && meta.mcaptchaSitekey && meta.mcaptchaInstanceUrl) {
await this.captchaService.verifyMcaptcha(meta.mcaptchaSecretKey, meta.mcaptchaSitekey, meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { await this.captchaService.verifyMcaptcha(meta.mcaptchaSecretKey, meta.mcaptchaSitekey, meta.mcaptchaInstanceUrl, body['m-captcha-response']);
throw new FastifyReplyError(400, err); }
});
}
if (meta.enableRecaptcha && meta.recaptchaSecretKey) { if (meta.enableRecaptcha && meta.recaptchaSecretKey) {
await this.captchaService.verifyRecaptcha(meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { await this.captchaService.verifyRecaptcha(meta.recaptchaSecretKey, body['g-recaptcha-response']);
throw new FastifyReplyError(400, err); }
});
}
if (meta.enableTurnstile && meta.turnstileSecretKey) { if (meta.enableTurnstile && meta.turnstileSecretKey) {
await this.captchaService.verifyTurnstile(meta.turnstileSecretKey, body['turnstile-response']).catch(err => { await this.captchaService.verifyTurnstile(meta.turnstileSecretKey, body['turnstile-response']);
throw new FastifyReplyError(400, err); }
} catch (err) {
logger.error(`Invalid request: captcha verification failed: ${err}`);
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
}); });
} }
} }
@ -229,7 +230,7 @@ export class SigninApiService {
} catch (e) { } catch (e) {
logger.error('Invalid request: Unable to authenticate with two-factor token.'); logger.error('Invalid request: Unable to authenticate with two-factor token.');
return await fail(403, { return await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
}); });
} }
@ -251,7 +252,7 @@ export class SigninApiService {
} else { } else {
logger.error('Invalid request: Unable to authenticate with WebAuthn credential.'); logger.error('Invalid request: Unable to authenticate with WebAuthn credential.');
return await fail(403, { return await fail(403, {
id: '93b86c4b-72f9-40eb-9815-798928603d1e', id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
}); });
} }
} else { } else {

View file

@ -94,7 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.host == null) { if (ps.host == null) {
q.andWhere('emoji.host IS NOT NULL'); q.andWhere('emoji.host IS NOT NULL');
} else { } else {
q.andWhere('emoji.host = :host', { host: this.utilityService.toPuny(ps.host) }); q.andWhere('emoji.host = :host', { host: this.utilityService.normalizeHost(ps.host) });
} }
if (ps.query) { if (ps.query) {

View file

@ -51,14 +51,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private fetchInstanceMetadataService: FetchInstanceMetadataService, private fetchInstanceMetadataService: FetchInstanceMetadataService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.normalizeHost(ps.host) });
if (instance == null) { if (instance == null) {
throw new ApiError(meta.errors.instanceNotFound); throw new ApiError(meta.errors.instanceNotFound);
} }
const followingCount = await this.followingsRepository.countBy({ followerHost: this.utilityService.toPuny(ps.host) }); const followingCount = await this.followingsRepository.countBy({ followerHost: this.utilityService.normalizeHost(ps.host) });
const followersCount = await this.followingsRepository.countBy({ followeeHost: this.utilityService.toPuny(ps.host) }); const followersCount = await this.followingsRepository.countBy({ followeeHost: this.utilityService.normalizeHost(ps.host) });
await this.federatedInstanceService.update(instance.id, { await this.federatedInstanceService.update(instance.id, {
followingCount: followingCount, followingCount: followingCount,

View file

@ -51,7 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private utilityService: UtilityService, private utilityService: UtilityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.normalizeHost(ps.host) });
if (instance == null) { if (instance == null) {
throw new ApiError(meta.errors.instanceNotFound); throw new ApiError(meta.errors.instanceNotFound);

View file

@ -40,7 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.normalizeHost(ps.host) });
if (instance == null) { if (instance == null) {
throw new Error('instance not found'); throw new Error('instance not found');

View file

@ -49,6 +49,7 @@ export const paramDef = {
properties: { properties: {
roleId: { type: 'string', format: 'misskey:id' }, roleId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' }, userId: { type: 'string', format: 'misskey:id' },
memo: { type: 'string' },
expiresAt: { expiresAt: {
type: 'integer', type: 'integer',
nullable: true, nullable: true,
@ -90,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return; return;
} }
await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null, me); await this.roleService.assign(user.id, role.id, ps.memo, ps.expiresAt ? new Date(ps.expiresAt) : null, me);
}); });
} }
} }

View file

@ -36,6 +36,7 @@ export const meta = {
id: { type: 'string', format: 'misskey:id' }, id: { type: 'string', format: 'misskey:id' },
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },
user: { ref: 'UserDetailed' }, user: { ref: 'UserDetailed' },
memo: { type: 'string', nullable: true },
expiresAt: { type: 'string', format: 'date-time', nullable: true }, expiresAt: { type: 'string', format: 'date-time', nullable: true },
}, },
required: ['id', 'createdAt', 'user'], required: ['id', 'createdAt', 'user'],
@ -93,6 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
id: assign.id, id: assign.id,
createdAt: this.idService.parse(assign.id).date.toISOString(), createdAt: this.idService.parse(assign.id).date.toISOString(),
user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
memo: assign.memo,
expiresAt: assign.expiresAt?.toISOString() ?? null, expiresAt: assign.expiresAt?.toISOString() ?? null,
}))); })));
}); });

View file

@ -177,6 +177,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
}, },
memo: {
type: 'string',
optional: false, nullable: true,
}
}, },
}, },
}, },
@ -268,6 +272,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
createdAt: this.idService.parse(a.id).date.toISOString(), createdAt: this.idService.parse(a.id).date.toISOString(),
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
roleId: a.roleId, roleId: a.roleId,
memo: a.memo,
})), })),
}; };
}); });

View file

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

View file

@ -114,7 +114,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> { private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
// ブロックしてたら中断 // ブロックしてたら中断
const fetchedMeta = await this.metaService.fetch(); const fetchedMeta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) return null; if (this.utilityService.isItemListedIn(this.utilityService.extractHost(uri), fetchedMeta.blockedHosts)) return null;
let local = await this.mergePack(me, ...await Promise.all([ let local = await this.mergePack(me, ...await Promise.all([
this.apDbResolverService.getUserFromApId(uri), this.apDbResolverService.getUserFromApId(uri),
@ -122,6 +122,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
])); ]));
if (local != null) return local; if (local != null) return local;
const host = this.utilityService.extractHost(uri);
// local object, not found in db? fail
if (this.utilityService.isSelfHost(host)) return null;
// リモートから一旦オブジェクトフェッチ // リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver(); const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri) as any; const object = await resolver.resolve(uri) as any;
@ -136,10 +141,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (local != null) return local; if (local != null) return local;
} }
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
return await this.mergePack( return await this.mergePack(
me, me,
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null, isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
); );
} }

View file

@ -44,7 +44,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const instance = await this.instancesRepository const instance = await this.instancesRepository
.findOneBy({ host: this.utilityService.toPuny(ps.host) }); .findOneBy({ host: this.utilityService.normalizeHost(ps.host) });
return instance ? await this.instanceEntityService.pack(instance, me) : null; return instance ? await this.instanceEntityService.pack(instance, me) : null;
}); });

View file

@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -81,11 +82,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private utilityService: UtilityService, private utilityService: UtilityService,
private followingEntityService: FollowingEntityService, private followingEntityService: FollowingEntityService,
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null const user = await this.usersRepository.findOneBy(ps.userId != null
? { id: ps.userId } ? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); : { usernameLower: ps.username!.toLowerCase(), host: ps.host ? this.utilityService.normalizeHost(ps.host) : IsNull() });
if (user == null) { if (user == null) {
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
@ -93,23 +95,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.followersVisibility === 'private') { if (profile.followersVisibility !== 'public' && !await this.roleService.isModerator(me)) {
if (me == null || (me.id !== user.id)) { if (profile.followersVisibility === 'private') {
throw new ApiError(meta.errors.forbidden); if (me == null || (me.id !== user.id)) {
}
} else if (profile.followersVisibility === 'followers') {
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden); throw new ApiError(meta.errors.forbidden);
} }
} else if (profile.followersVisibility === 'followers') {
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}
}
} }
} }

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